Select

A React select component with a searchable listbox menu - for country, timezone and status pickers.

Select playground

Committing an option closes the menu and returns focus to the trigger.

Install Select

1. New project? One command installs the package and wires everything - see Installation:

bash
pnpm dlx zyncat-ui init

2. Import the component - it loads its own stylesheet:

tsx
import { Select } from '@zyncat/ui/select';

Usage

tsx
import { Select } from '@zyncat/ui/select';
 
<Select ariaLabel="Timezone" value={tz} onChange={setTz} options={TIMEZONES} searchable />

Compose Select in your application. No Tailwind or external styling library is required; every value resolves from Zyncat UI's token vocabulary.

Select props

Select

PropTypeDefaultDescription
options*SelectOption[] | SelectGroup[][]The choices - a flat `SelectOption[]`, or `SelectGroup[]` to render labeled sections.
valuestring | null—Controlled value - matches an option's `value`, or `null` for none. Omit for uncontrolled (use `defaultValue`).
defaultValuestring | nullnullInitial value when uncontrolled.
onChange(value: string, option: SelectOption) => void—Fires on commit - gets the new `value` and its full `SelectOption`. Committing closes the menu.
placeholderstring'Select an option'Trigger text when nothing is selected.
size'sm' | 'md' | 'lg''md'Trigger height, type and padding. The menu follows it unless `menuSize` overrides.
menuSizeMenuSizethe `size` valueMenu density on its own - row type, row padding and the filter field. A row carrying a description sits one step taller than a plain one at every step.
weightMenuWeight'medium'Weight of every option label in the menu.
widthMenuWidth'trigger'Menu width. `trigger` is never narrower than the trigger, `auto` fits the options, and `sm` | `md` | `lg` are fixed steps with long labels ellipsizing.
disabledbooleanfalseDisabled - trigger is inert and the menu cannot open.
invalidbooleanfalseDanger ring + border.
loadingbooleanfalseSkeleton rows in the menu; trigger reads "Loading...".
searchablebooleanfalseType-to-filter field pinned above the list.
searchPlaceholderstring'Filter options'Placeholder for the `searchable` filter input.
highlight'neutral' | 'accent''neutral'Hue of the highlight that travels between options: the neutral wash, or the accent wash with accent ink on the active option.
railbooleanfalseShort accent bar on the leading edge of the highlight, marking the active option.
leadingIconReactNode—Your own icon node pinned before the trigger label; else the selected option's icon.
idstring—Base id for the trigger/menu/list ids and a11y wiring; auto-generated if omitted.
ariaLabelstring—Accessible name for the trigger and listbox - supply when there is no visible label.
...htmlAttributesHTMLAttributes<HTMLDivElement> & DataAttributes—Standard <div> attributes (className, style, data-*, ...) forwarded to the select root.
menuPropsSelectMenuHtmlProps—Standard attributes (className, style, data-*, ...) forwarded to the menu panel, which portals to <body> and inherits nothing from the root.
activateOnActivateOn'pointerdown'Whether the trigger and the options fire on `pointerdown` (snappier) or wait for `click`.
animationDisableableAnimationduration 'base' + ease 'entrance'/'exit'Menu open/close timing - motion tokens only, or `null` to disable.
triggerPropsSelectTriggerHtmlProps—Standard <button> attributes (className, style, aria-*, data-*, ...) merged onto the trigger. `onClick` and `onKeyDown` run before the built-in open/close and arrow-key handling, which cannot be replaced - the trigger is the combobox. Ignored when `trigger` replaces it.
triggerCustomTrigger<SelectOption | null>—Your own element in place of the built-in trigger. It is cloned with the combobox wiring - role, aria, open/close, arrow keys, and the anchor the menu measures - so it must render one focusable element that forwards its props and ref. Pass a function to read `{ open, selected }`.
showCheckboolean—Show check icon on selected value

SelectGroup

PropTypeDefaultDescription
labelstring—Section heading rendered above the options; omit for an unlabeled group.
options*SelectOption[]—The options in this section.

SelectOption

PropTypeDefaultDescription
value*string—The stored value - what `onChange` returns and `value` matches; must be unique.
label*ReactNode—Primary row text, and the trigger label once selected; also matched by `searchable`.
descriptionstring—Optional secondary line under the label; also matched by `searchable`.
iconReactNode—Your own icon node shown before the label.
disabledbooleanfalseNot selectable - skipped by keyboard nav and typeahead, and marked `aria-disabled`.
searchTextstring—Text used for `searchable` filtering and typeahead. Required when `label` is not a string.

Frequently asked questions

Set the searchable prop - <Select searchable options={options} value={v} onChange={setV} /> pins a filter input above the list and narrows it as you type. The filter matches an option's label, its description and its searchText, searchPlaceholder changes the input placeholder, and an empty result reads "No matches for ...".

Both, in the roles ARIA gives them. The trigger is a button with role="combobox" carrying aria-haspopup="listbox", aria-expanded, aria-controls and aria-activedescendant, and the popover holds a role="listbox" whose rows are role="option" with aria-selected - so a screen reader announces the active option while the keyboard stays on the list, or on the filter input when searchable is on.

You cannot restyle a native <select> menu because the browser draws it, so this renders its own trigger and a portalled listbox you style like any other element. size takes sm, md or lg, highlight switches the travelling highlight between the neutral and accent wash, rail adds an accent bar on its leading edge, and htmlProps forwards className, style and data-* to the root.

Pass value and onChange: onChange(value, option) fires on commit with the value string and the full SelectOption, so <Select value={tz} onChange={setTz} options={TIMEZONES} /> is a controlled select. Omit value and pass defaultValue to let it hold its own state; either way, committing closes the menu and returns focus to the trigger.

Yes. Each SelectOption takes an icon node, a description line under the label, disabled and searchText, and passing SelectGroup[] instead of a flat array renders labelled sections. leadingIcon pins your own icon on the trigger; without it the trigger shows the selected option's icon.

Yes - the trigger prop replaces the built-in one. Pass an element, <Select trigger={<Button>Filter</Button>} options={options} />, or a function of the selection state, trigger={({ open, selected }) => ...}, where selected is the full SelectOption or null. Your element is cloned with the whole combobox contract - role="combobox", aria-expanded, aria-controls, aria-activedescendant, the arrow keys, the open and close press, and the anchor the menu measures and matches its width to - so it has to render one focusable element that forwards its props and its ref. placeholder, leadingIcon and triggerProps stop applying once you supply one; size still sets the menu density, loading still blocks opening and disabled still makes it inert.

Arrow Down or Arrow Up opens it from the trigger; inside, the arrows, Home and End move the active option, Enter commits, Escape closes and returns focus, and Tab closes. With searchable off, Space commits and typing jumps to an option by prefix. It ships as compiled ESM with its 'use client' directive intact for the Next.js App Router, has zero runtime dependencies, and collapses every duration to 1ms under prefers-reduced-motion.

Set in Geist & Newsreader — animated by the house engineZyncat UI · Rev 0.11 · MIT · Built by Tabsir Ahammed · Source on GitHub