The kit's picker: a control button that opens a floating, keyboard-navigable option list with an optional filter field. It is composed on Dropdown for positioning, Select itself owns selection, search, and ARIA, never the overlay mechanics.
One component, two modes. By default it picks one value and closes. Pass multiple and it picks several, keeps the panel open while you toggle, and can restate what is applied as removable chips under the control.
The canon behind it. docs/design-language/07-toolbars-and-filters.md §A2 (07-toolbars-and-filters.md:18, every facet option carries a live count and an icon), §A4 (07-toolbars-and-filters.md:24, a count-only trigger owes the reader removable chips), and docs/design-language/10-overlays.md §A7 (10-overlays.md:39, the positioning primitive Select composes on).
When to reach for it#
Any choice from a list of options, one value or several, especially when the list needs search (auto-on above 8 options), grouping, or per-option icons. This is the one Select; there is deliberately no wrapper component for defaults and no second component for multi-select, reach for multiple, a preset, and explicit props instead.
The multi mode landed on 2026-08-16 (owner ruling, doc docs/plan/52-select-gap.md). Before it, both product apps had to hand-roll one: walmart-mvp built MultiFacetControl at walmart-mvp/frontend/src/components/FacetToolbar.tsx:65-135, whose own comment at line 76 says it "wears the kit Select's exact control skin (sm scale), so single and multi facets read as one family in a toolbar", and the kit's own FacetBar shipped a private copy of the same control. Both are answered by multiple now. Do not build a third: if multiple cannot express what a surface needs, the fix is a prop on Select.
Contract#
SelectProps is a discriminated union on multiple (select.types.ts:150-153). A single Select rejects an array value. A multiple one rejects a bare value. chips and label are typed never in single mode (select.types.ts:119-120), so passing them is a compile error rather than a prop that does nothing.
Options and selection:
options:SelectOptions<T, D>, bare strings orSelectOptionobjects, auto-normalized (normalizeOptions,select.tsx:73-80). A bare string becomes{ value, label, labelText }.value/onChange(value, option): value-first, singleT | undefined.onChange's second argument is the resolved option (including itsdatapayload) when one exists. Multiple mode's shapes are in the section below.SelectOption.group: grouping is implemented, not deferred. Options sharing agroupstring render under a small uppercase sticky header (select.tsx:419-427), and adjacent same-group options are required to stay adjacent for the header to render correctly. There is no confirmed shipped call site usinggroupin either app as of this pass, but the feature itself is live in both the type and the renderer, not a week-2 promise.SelectOption.description/SelectOption.count(select.types.ts:32-34): an optional second line under the label (smaller, lighter ink) and a trailing count. Both render in every option row (select.tsx:448-460), anddescriptionjoins the search haystack (select.tsx:179), so a searchable Select matches description words too (owner ruling 2026-08-14, the surface task #34 shipped).preset:SelectPreset,"simple"or"facet", applies a bundle of defaults before explicit props (select.tsx:44-54). A preset is a shorthand and never a gate: everything it sets can be set by hand, and an explicit prop always wins.
Appearance and behavior:
placeholder,disabled,loading(renders a skeleton in the control's place),size(sm | md | lg),fullWidth,clearable(inline clear affordance once something is selected; in multiple mode it empties the whole array),error(border-only styling, no message slot of its own),warning(the warning edge for a soft issue;errorwins when both are set),noAnimate.searchable: forces the in-panel filter field on or off. Default is auto, on above 8 options (SEARCH_AUTO_THRESHOLD,select.tsx:58).searchPlaceholder,emptyLabel(shown when nothing matches).name: native-form mode, mirrors the selection into hidden inputs under this field name, for a plain HTML form submit. Multiple mode writes one input per selected value (select.tsx:499-504).placement(floating-ui placement, default"bottom-start"),noPortal(render the panel inline instead of through a portal)."aria-label": names the closed control for assistive tech; without it the trigger announces only its visible text (the placeholder or the picked value's label).SelectOption.trailing/SelectOption.swatch: a right-side helper before the affordance (a unit, a short status, a small node), and a CSS colour rendered as a dot on the row's right and beside the control's value while that row is selected; a swatched row's hover wash is its own colour, so scanning the list previews the tone (SchemaForm'sx-option-metamaps onto both).- Per-option
icon, andIconImportProps(Icon/iconSize/iconProps/iconClassName) for the control's own icon, shown when nothing is selected or when the selected option has no icon of its own. - Slot classnames per
SelectClassNames: outer wrapper (_), control box, control text, panel, option, search, chips (the applied-chips row, multiple mode only).
Multiple mode#
multiple turns the same component into a multi-value picker. The panel stays open while you toggle, so picking three values is three clicks and not three re-opens (select.tsx:156).
value?: readonly T[],onChange?: (value: T[], options: SelectOption<T, D>[]) => void. The handler gets the whole next selection, never a delta. Toggling appends to the end and removing filters in place, so the array order is the order the user picked.- The panel is
role="listbox" aria-multiselectableand every row carriesaria-selected(select.tsx:408-409). The row's trailing affordance is a checkbox instead of the single mode's check glyph. That box is decoration: the row owns the click and the ARIA, and the box never takes focus. label?: ReactNode: the property word the closed control leads with, rendered "Owner · 2". Without it the control shows the first pick and "+N" for the rest (select.tsx:303-318). Reach forlabelin a toolbar, where a narrow control cannot fit a list of names.chips?: boolean: restates each applied value as a removableChip presets="applied"in a row under the control (select.tsx:516-534). Removing a chip removes that value.clearableempties the array.namewrites one hidden input per value.searchable,emptyLabel,size,options,disabled,errorand the rest of the surface behave exactly as they do in single mode.
Keyboard#
| Key | Multiple mode | Single mode |
|---|---|---|
| Arrow down / up | Move the active row, skipping disabled | Same |
| Home / End | Jump to the first or last enabled row | Same |
| Enter | Toggle the active row, panel stays open | Select and close |
| Space | Toggle the active row, panel stays open | Opens the panel when closed |
| Escape | Close, focus returns to the control | Same |
| Typing | Filters the list when the search field is shown | Same |
Space commits only where it cannot be a typed character, so it toggles on the list and on the control but never inside the search box (select.tsx:264-276). Focus returns to the control whenever the panel that held it closes (select.tsx:205-211), in both modes.
preset="facet"#
The toolbar shorthand: multiple, chips, and size="sm" (select.tsx:50-54). Presets are shorthands, so any of the three can be overridden by an explicit prop. preset="facet" implies the mode, so callers do not repeat multiple.
When to reach for chips#
Turn chips on when the closed control can only summarize. A count ("Owner · 2") tells the reader how many filters are on but not which, and a filter the reader cannot name is a filter they will forget is there. Leave chips off when the control already names the selection, or when a parent gathers every facet's chips in one row after the controls, which is what FacetBar does (canon doc 07-toolbars-and-filters.md §A4).
Keyboard and accessibility#
The control is aria-haspopup="listbox", the panel is a role="listbox" of role="option" rows, and multiple mode adds aria-multiselectable. When the search field is shown, it is the role="combobox" driver and keyboard focus lands there on open; otherwise the list itself takes focus. Arrow keys move the active row (skipping disabled options), Home/End jump to the ends, Enter selects or toggles, Escape closes and hands focus back to the control. The active row is tracked with aria-activedescendant and scrolled into view by scrolling the listbox only, never the page. This closed the accessibility gap of the component's predecessor (SearchSelectV2), which had no listbox role and no arrow navigation. The per-key table above covers where the two modes differ.
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
preset="simple" size="sm" noPortal inside a kit Modal | A select whose panel renders correctly on top of a native <dialog> | speedway/app/components/FilePreview.tsx:224-233 (worksheet picker), walmart-mvp/frontend/src/pages/ImportParts.tsx:519-524 (duplicate-handling table, inside Modal elementId={DUPLICATE_CHECK_MODAL} at line 463) | The code comment at FilePreview.tsx:230-232 states it directly: "The modal is a native <dialog> (top layer); a body-portaled panel would paint invisibly underneath it." Any Select inside a Modal needs noPortal for the same reason |
size="sm" value={...} onChange={...} with no preset, no searchable override | The default toolbar/table-cell select | speedway/app/routes/workspaces/schemas/attributes.tsx, walmart-mvp/frontend/src/features/mapping/MappingEditor.tsx (5 call sites) | The plain single-facet or single-field-mapping shape, letting the auto search threshold decide |
multiple size="sm" label={...} with the parent owning the chips | A toolbar facet that summarizes as "Owner · 2" and leaves the applied chips to a row gathered after every control | packages/ui/src/table/facet-bar.tsx:253-264 (every multi facet in the kit's own toolbar) | FacetBar collects chips for all facets in one place, so each control keeps chips off and only reports its count |
preset="facet" on a standalone facet | The same control with its own chips row underneath, no parent needed | apps/playground/src/app/components/select/examples/facet-preset.tsx | A facet that lives outside a FacetBar has nowhere else to put its applied values, so the preset turns chips on |
clearable inside FilterBar | A facet whose value can be reset from the control itself, not just by re-picking the "all" option | speedway/app/routes/workspaces/review/log.tsx:685, speedway/app/components/FilterBar.tsx:126-148 | Canon doc 7 covers single-select facets as the preferred shape precisely because they carry their selection visibly and clear cleanly |
Banned combinations#
- A second multi-select control built beside this one. The private copies are retired:
FacetBarnow composesSelect multiplefor its multi facets, and walmart'sMultiFacetControlis the shape this mode replaces. If a surface needs somethingmultiplecannot express, add the prop here rather than starting a third control. - A facet whose values can contain the URL list delimiter, built as a multi-select. Canon doc 7 §A4 states this as a hard constraint independent of the kit: "a facet over values that can contain the URL list delimiter (part types with commas) must be single-select." This is why walmart's part-type facet stays single-select. The
multiplemode does not lift it, because the constraint is about the URL, not the control. - A
Selectpanel inside aModalwithoutnoPortal. It will paint underneath the native<dialog>'s top layer and be invisible or unreachable; see the sanctioned-combinations row above for the fix.
Before you adopt this#
Five questions to answer before reaching for Select.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, Select is a form control; no shell already renders it.
- Does this app already ship a local implementation of the same thing?
MultiFacetControlandFacetBar's private copy are retired;multiplereplaces both. - Does this app's kit pin reach the version this component or prop landed in?
multiplemode landed 2026-08-16 (doc 52); confirm the app's kit pin reaches it. - Does the component derive its own accessible name and keyboard path, or must the call site supply them?
aria-labelnames the closed control; without it only the visible text announces. - Which canon §A rules bind this surface, and which does the composition break? §A4 owes removable chips once a trigger can only summarize as a count.
Travels with#
Dropdown, always,Selectcomposes on it for positioning and never reimplements overlay mechanics itself.Chip, in multiple mode:chipsrenders the applied values as removablepresets="applied"chips in a row under the control. A parent that gathers several facets' chips into one row keepschipsoff and renders them itself (canon doc 7 §A4, andFacetBar).Tooltip, in the toolbar context, wrapping a facet'sSelectto carry helper text and a "Selected: ..." line (FacetToolbar.tsxfacetTooltip).
Snippet#
<Select preset="simple" size="sm" aria-label="Worksheet" options={full.sheets} value={sheet ?? full.sheet ?? undefined} onChange={(v) => setSheet(v ?? null)} className="w-56" noPortal/>speedway/app/components/FilePreview.tsx:223-234
And the multi-value shape, a facet that carries its own chips:
<Select multiple chips clearable size="sm" label="Owner" placeholder="Any owner" aria-label="Filter by owner" options={OWNERS} value={owners} onChange={setOwners}/>apps/playground/src/app/components/select/examples/multi-select-chips.tsx