Skip to content

Select

Dropdown select with search, multiple selection, card variant, and controlled keyboard integration points. 8 composable parts.

Preview

Installation

$pnpm exec dgadd add ui/select
[Installs to]src/components/ui/select[Item]ui/select

dgadd is not public on npm yet. Until the first release, pack @diffgazer/add from the repository and install that tarball into this app, which is what puts dgadd on pnpm exec.

UI components require Tailwind CSS v4. Local copy mode imports src/styles/styles.css; package mode uses @diffgazer/ui CSS once packages are available.

Usage

tsx
import {  Select,  SelectContent,  SelectItem,  SelectTrigger,  SelectValue,} from "@/components/ui/select";export default function SelectDefault() {  return (    <div className="w-64">      <Select>        <SelectTrigger aria-label="Branch">          <SelectValue placeholder="Select a branch..." />        </SelectTrigger>        <SelectContent>          <SelectItem value="main">main</SelectItem>          <SelectItem value="develop">develop</SelectItem>          <SelectItem value="feature/auth">feature/auth</SelectItem>          <SelectItem value="fix/typo">fix/typo</SelectItem>          <SelectItem value="release/v2">release/v2</SelectItem>        </SelectContent>      </Select>    </div>  );}

Examples

Open Listbox (defaultOpen)

Preview

Disabled and Invalid

Preview

Searchable (bottom)

Preview

Searchable (top)

Preview

Multiple

Preview

Display Modes

Preview

Avatar Picker (render children)

Preview

Tags (Multiselect)

Preview

Radio Style

Preview

Card Variant

Preview

API Reference

Select

NameTypeDefaultDescription
valuestring | string[]Controlled selected value. string[] when multiple, string in single mode.
defaultValuestring | string[]Initial selected value for uncontrolled usage.
onChange((value: string) => void) | ((value: string[]) => void)Called when the selection changes.
openbooleanControlled open state. An initially true value initializes without moving focus or scrolling; later false-to-true transitions focus the content and reveal the active option.
defaultOpenbooleanfalseInitial open state for uncontrolled usage. The list initializes without moving document focus or scrolling, which is useful for an immediately visible variant="card" layout.
onOpenChange(open: boolean) => voidCalled when open state changes.
multiplebooleanfalseEnable multi-select. value/onChange become string[].
variant"default" | "card""default"Visual treatment. "card" renders the inline settings-panel layout (combine with defaultOpen).
width"sm" | "md" | "lg" | "full"Width preset for the Select container. "full" fills the parent.
disabledbooleanfalseDisable the trigger and prevent open.
namestringName for the hidden form input that participates in native form submission.
requiredbooleanfalseMark the select as required for native form validation.
idstringID applied to the Select trigger button, not the wrapper element. Field uses it to point its label at the focusable control.
highlightedstring | nullControlled highlighted item id. Pair with onHighlightChange.
onHighlightChange(value: string | null) => voidCalled when the highlighted item changes via keyboard or search.

SelectTrigger

NameTypeDefaultDescription
childrenrequiredReactNodeTrigger label. Use SelectValue or SelectTags for selection display.
handleReactNode | nullChevronCustom trigger handle. Pass null to hide the default chevron.
aria-labelstringAccessible name for the trigger. When present, it takes precedence over aria-labelledby.
aria-labelledbystringField label id when composed with FieldID reference for the trigger label. Field.Control supplies this automatically when SelectTrigger is wrapped in Field.

SelectContent

NameTypeDefaultDescription
getResultsLabel(count: number) => stringcount => count + (count === 1 ? " result" : " results")Localizes the searchable results count announced by the live region when a query is present.

SelectValue

NameTypeDefaultDescription
placeholderReactNode"Select..."Rendered when nothing is selected.
display"count" | "list" | "truncate""count"Multi-select display mode. "count" shows N selected, "list" comma-separates, "truncate" shows first N + "+M more".
truncateAfternumber2Number of items shown before "+N more" when display="truncate".
getSelectedLabel(count: number) => stringcount => count + " selected"Localizes the multi-select summary rendered when display="count".
getOverflowLabel(count: number) => stringcount => " +" + count + " more"Localizes the overflow suffix rendered when display="truncate".
children(state: { selected: string[]; labels: ReadonlyMap<string, string> }) => ReactNodeRender-function child for fully custom selection display; static ReactNode children are not accepted. Example: selected.map((value) => labels.get(value) ?? value).join(', ').

SelectTags

NameTypeDefaultDescription
placeholderstring"Select..."String rendered when nothing is selected. Only available in multi-select mode.

SelectSearch

NameTypeDefaultDescription
placeholderstring"Search..."Placeholder shown in the search input when its value is empty.
position"top" | "bottom""bottom"Where the search row renders relative to the option list. "bottom" (default) renders it below the list with a top border; "top" renders it above with a bottom border.
aria-labelstring"Search options" unless aria-labelledby is presentAccessible name for the search combobox. A composed Field label takes precedence through aria-labelledby.

SelectItem

NameTypeDefaultDescription
valuerequiredstringItem value. Must be unique within the Select.
indicator"auto" | "checkbox" | "radio" | "none""auto"Selection indicator style. "auto" picks checkbox in multi mode and a check mark in single mode.
textValuestringOverride the searchable/typeahead text when children are not plain text.
disabledbooleanfalseDisable the option.

SelectEmpty

NameTypeDefaultDescription
childrenReactNode"> no results."Custom empty-state content shown when a nonempty search query matches no items.

Data attributes

AttributeApplies toValuesDescription
data-stateSelectTrigger and SelectContent/FloatingPanel listbox"open" | "closed"Reflects the listbox open state for trigger and floating panel styling.
data-disabledSelectTriggerpresent when disabledMarks a disabled trigger for styling hooks.
data-highlightedSelectItempresent when highlightedMarks the option referenced by aria-activedescendant.
data-valueSelectItemitem valueExposes the option value used by selection and typeahead.
data-labelSelectItemresolved label textExposes the option label used by SelectValue and search/typeahead.

Accessibility

Keyboard Navigation

SelectTrigger follows the closed combobox key map. SelectContent handles listbox navigation and restores focus to the trigger on Escape and Tab.

KeyAction
Enter / SpaceOn the trigger, toggles the listbox. In the listbox, selects the highlighted option.
ArrowDown / ArrowUpOpens the listbox from the trigger, then moves highlight through enabled options.
Home / EndOn a closed non-searchable trigger, opens and highlights the first or last enabled option. In the listbox, jumps to the first or last enabled option.
Printable characterOn a closed non-searchable trigger, opens and typeaheads to the matching option. In an open non-searchable listbox, typeahead updates the highlight.
TabCloses the open listbox and synchronously restores focus to the trigger; single-select commits the highlighted option first.
EscapeCloses the open listbox and returns focus to the trigger.
Search input ArrowDown / ArrowUpMoves the active descendant through filtered enabled options when SelectSearch is focused.
Search input EnterSelects the active filtered option when one is visible.

Searchable with keyboard navigation

Preview

Notes

Requires @diffgazer/keys (package mode)

Select's built-in keyboard navigation imports from the required @diffgazer/keys peer. Package consumers must install @diffgazer/keys with @diffgazer/ui; packages are not yet published to npm, so until the first release install both from locally packed tarballs. Importing @diffgazer/ui/components/select without keys fails at module load with an error naming the missing @diffgazer/keys package. Copy/dgadd consumers do not need the package — copy mode rewrites the keyboard hooks to local source.

Composition Contract

Use Select.Trigger, Select.Content, Select.Item, and the other Select parts as explicit children in the Select JSX tree. Custom option UI belongs inside Select.Item, with textValue when the visible content is not plain text. Components that create items internally from an opaque wrapper are not part of the current public contract.

Card Variant

Set variant='card' on Select for a settings-panel layout with checkbox-style indicators and a thick border treatment. The list still respects open/defaultOpen — pass defaultOpen to render the inline list immediately, or pair with open/onOpenChange to control expansion. The card trigger is an inverted --foreground header, so it takes its keyboard focus indicator as an outside outline (outline-2 outline-ring outline-offset-0) drawn on the page background; the default trigger keeps the inset Input focus ring.

Multiple Selection

Set multiple={true} to enable multi-select. Value becomes string[] instead of string. Items show [x]/[ ] checkbox indicators in multiple mode by default. The dropdown stays open after selection.

Item Indicators

Control the visual indicator on SelectItem via the indicator prop: 'auto' (default, [x]/[ ] in multi, ✓ in single), 'checkbox' (always [x]/[ ]), 'radio' ([ • ]/[ ] radio-style), or 'none' (text-only, no prefix). Use 'none' for tag-style multiselect, 'radio' for single-choice lists.

SelectTags vs SelectValue

Use SelectTags inside SelectTrigger for multiselect. It renders each selected item as an outlined chip showing the option label, and its placeholder accepts a string. Use SelectValue for single-select or multiselect with display modes: 'count' (default, 'N selected'), 'list' (comma-separated), or 'truncate' (first N + '+M more'). Its placeholder accepts ReactNode.

Search Position

SelectSearch can go anywhere inside SelectContent — its position prop, not its JSX order, decides where it renders. position='bottom' (default) renders the search row below the option list with a top border; position='top' renders it above the list with a bottom border. DOM and Fragment wrappers around SelectSearch or SelectEmpty are preserved when those parts are hoisted outside the listbox.

Searchable APG semantics

When SelectSearch is present, Select implements the WAI-ARIA Editable Combobox With List Autocomplete pattern: the search input is the combobox (role='combobox', aria-controls -> listbox, aria-activedescendant on focused option, aria-autocomplete='list'). The trigger button reduces to a simple toggle (aria-haspopup='listbox' + aria-expanded). When no SelectSearch is present, the trigger button itself is the combobox following the Combobox With Listbox Popup pattern. This avoids splitting combobox state across two controls.

Empty State

Add SelectEmpty inside SelectContent to show a message after a nonempty search query matches no items. It renders nothing before a query is entered, including when the option list is empty. Default: '> no results.' Pass children to customize.

Keyboard Navigation

SelectContent includes built-in keyboard navigation via useNavigation: ArrowUp/Down to move, Enter/Space to select, Home/End to jump, Tab commits the highlighted single-select option and closes, and Escape closes. Highlight state is exposed via highlighted/onHighlightChange props on Select for external integration.

Custom Trigger Content

For advanced customization, pass a render function as children to SelectValue: <SelectValue>{({ selected, labels }) => selected.map((value) => labels.get(value) ?? value).join(', ')}</SelectValue>. Use the public Select parts for custom trigger and value layouts.

Keyboard focus on the trigger is variant-owned. The default trigger uses the inset field ring shared with Input (focus:border-ring focus:ring-1 focus:ring-ring). The card trigger is an inverted --foreground header, where an inset --ring resolves to the same value as the surface it sits on and disappears — so it draws its indicator outside instead (focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-0), on the page background where it keeps full contrast in both themes.

The card-variant selected-state indicator pulses via the motion-safe:animate-pulse Tailwind class. The motion-safe: prefix gates the animation behind the user's prefers-reduced-motion setting — the class is part of the public CSS contract for the card variant. When prefers-reduced-motion: reduce is active, the pulse never plays.

Source

Install via CLI: pnpm exec dgadd add ui/select. Keyboard hooks are included as standalone copies. For the full experience, use --integration keys.

Highlighted source loads after this disclosure opens. Browse the source repository.