A custom listbox: a recessed field or filter chip that opens a frosted popover, optionally searchable.
Select replaces the native <select> with a combobox and a listbox popover that keeps the design system's look while matching native keyboard behaviour. It has two appearances: field (a grey frosted recess, so a choice reads as something you press rather than type in) and chip (the compact filter dropdown used in a FilterBar).
Options can be grouped (consecutive options sharing group), described (a second line), disabled, and counted (filter dropdowns show how many rows each option would show). searchable adds a pinned search well with keyword matching for long lists.
The popover portals to document.body, flips above the trigger when there is no room below, and returns focus to the trigger on close.
Installation
npx shadcn@latest add KezLahd/rime/selectAlso installs: base, field, icon, search-field, text-input.
Usage
import { Field, Select } from "@/components/ui";
<Field label="Topic">
<Select
value={topic}
onChange={setTopic}
placeholder="Choose a topic"
options={[
{ value: "billing", label: "Billing and invoices" },
{ value: "access", label: "Account access" },
]}
/>
</Field>Examples
Open
Field appearance
Chip appearance (filters)
Searchable, grouped
Invalid and disabled
Choose a reason.
API
Generated from the component's TypeScript types and JSDoc (scripts/gen-ui-registry.mjs), so it always matches the source.
Select
| Prop | Type | Default | Description |
|---|---|---|---|
| optionsRequired | ReadonlyArray<SelectOption<V>> | ||
| valueRequired | V | null | ||
| onChangeRequired | (value: V) => void | ||
| placeholder | string | "Select…" | |
| appearance | "field" | "chip" | "field" | field = bordered form control. chip = the reference recessed filter-bar chip. |
| size | "sm" | "md" | "md" | |
| disabled | boolean | ||
| invalid | boolean | ||
| required | boolean | ||
| id | string | ||
| name | string | Emits a hidden input so the value posts with a native form. | |
| active | boolean | Chip only: shows the "filter applied" tint. Defaults to value !== null. | |
| leadingIcon | ReactNode | ||
| className | string | ||
| searchable | boolean | false | A search box pinned at the top of the list. Typing filters by label, description, group and keywords, and highlights the match. For long lists (countries, cities, time zones); a short list reads faster without. |
| searchPlaceholder | string | "Search" | Placeholder, and accessible name, of the search box. |
| aria-label | string | ||
| aria-labelledby | string | ||
| aria-describedby | string | ||
| onBlur | () => void | ||
| inline | boolean | false | Render the list open, in place under the trigger: no portal, no focus move, no outside-click listener, no scrolling into view. For docs and thumbnails. |
Theme tokens
Per-component tokens are undeclared by default, so the default shown is the fallback the CSS uses. Set one at :root (or in theme.css) to restyle every instance. Live values are read from this page, so they follow the Rime Studio.
| Token | Default | Live value |
|---|---|---|
| --chip-height | var(--control-h-sm) | … |
| --chip-radius | var(--r-sm) | … |
| --field-focus-border | rgba(var(--rgb-brand), 0.7) / var(--focus-color) | … |
| --field-focus-ring | var(--focus-halo) | … |
| --field-focus-ring-invalid | 0 0 0 4px rgba(var(--rgb-danger), 0.14) | … |
| --field-hover-border | rgba(var(--rgb-brand-deep), 0.3) | … |
| --input-bg | var(--surface-solid) | … |
| --input-border | var(--line-input) | … |
| --input-border-width | var(--border-w-field) | … |
| --input-font-size | var(--text-input) | … |
| --input-height-md | var(--field-h-md) | … |
| --input-height-sm | var(--field-h-sm) | … |
| --input-pad-x | calc(14px * var(--density)) | … |
| --input-radius | var(--r-md) / var(--r-sm) | … |
| --input-shadow | var(--shadow-input) | … |
| --popover-item-active-bg | linear-gradient(to right, rgba(var(--rgb-brand), 0.14), rgba(var(--rgb-brand-deep), 0.07)) | … |
| --popover-item-radius | var(--r-xs) | … |
| --popover-pad | calc(6px * var(--density)) | … |
| --popover-radius | var(--r-lg) | … |
| --recess-fill-disabled | rgba(var(--rgb-white), 0.3) | … |
| --recess-line-disabled | rgba(var(--rgb-brand-deep), 0.1) | … |
System tokens it reads
| Token | Default | Live value |
|---|---|---|
| --blur-recess | tokens.css | … |
| --border-w | tokens.css | … |
| --border-w-field | tokens.css | … |
| --brand | tokens.css | … |
| --brand-deep | tokens.css | … |
| --chip-active-fill | tokens.css | … |
| --chip-active-fill-hover | tokens.css | … |
| --chip-active-shadow | tokens.css | … |
| --control-h-sm | tokens.css | … |
| --danger-base | tokens.css | … |
| --danger-field-fill | tokens.css | … |
| --density | tokens.css | … |
| --dur | tokens.css | … |
| --dur-fast | tokens.css | … |
| --ease | tokens.css | … |
| --ease-out | tokens.css | … |
| --field-h-md | tokens.css | … |
| --field-h-sm | tokens.css | … |
| --focus-color | tokens.css | … |
| --focus-halo | tokens.css | … |
| --focus-ring | tokens.css | … |
| --font-body | tokens.css | … |
| --font-mono | tokens.css | … |
| --ink-body | tokens.css | … |
| --ink-brand | tokens.css | … |
| --ink-disabled | tokens.css | … |
| --ink-muted | tokens.css | … |
| --ink-placeholder | tokens.css | … |
| --ink-secondary | tokens.css | … |
| --line-input | tokens.css | … |
| --line-soft | tokens.css | … |
| --popover-blur | tokens.css | … |
| --popover-border | tokens.css | … |
| --popover-shadow | tokens.css | … |
| --popover-surface | tokens.css | … |
| --r-2xs | tokens.css | … |
| --r-lg | tokens.css | … |
| --r-md | tokens.css | … |
| --r-sm | tokens.css | … |
| --r-xs | tokens.css | … |
| --recess-fill | tokens.css | … |
| --recess-fill-hover | tokens.css | … |
| --rgb-brand | tokens.css | … |
| --rgb-brand-deep | tokens.css | … |
| --rgb-contact | tokens.css | … |
| --rgb-danger | tokens.css | … |
| --rgb-white | tokens.css | … |
| --shadow-input | tokens.css | … |
| --shadow-recess | tokens.css | … |
| --surface-solid | tokens.css | … |
| --surface-sunken | tokens.css | … |
| --text-body | tokens.css | … |
| --text-caption | tokens.css | … |
| --text-input | tokens.css | … |
| --text-micro | tokens.css | … |
| --text-small | tokens.css | … |
| --tracking-label | tokens.css | … |
| --wash-active | tokens.css | … |
| --wash-strong | tokens.css | … |
| --wash-subtle | tokens.css | … |
| --z-popover | tokens.css | … |
Accessibility
- The trigger is role="combobox" with aria-expanded, aria-controls and aria-activedescendant; options are role="option" with aria-selected.
- Keyboard: Down, Up, Enter, Space, Home and End open it; Up/Down/Home/End/PageUp/PageDown move; typing jumps to the next label starting with those letters (closed, it selects); Enter or Space selects; Esc clears a search first, then closes, and stops there so an enclosing Modal stays open; Tab closes without selecting and moves on.
- Searchable lists announce the result count through a polite status region.
- A chip has no visible label, so aria-label is required on it.
- Wrap it in <Field label=…>: Field wires the label, hint and error to the control (id, aria-describedby, aria-invalid) through context, so the control needs no ids of its own.
Do and don't
Do
- Use appearance="chip" inside FilterBar, and "field" in forms.
- Turn on searchable past roughly 12 options.
- Use RadioGroup instead when there are 2 to 4 options a user should compare at a glance.
Don't
- Don't use a native <select>; it cannot take the theme.
- Don't put actions ("Add new…") inside the options; that is a Menu.
For AI agents
This page as markdown, rendered from the same registry entry. Point an agent at it (or add .md to this page's URL), or at /llms.txt for the index.
Open select.mdRelated: text-input, filter-bar, radio-group, menu