# Select

> A custom listbox: a recessed field or filter chip that opens a frosted popover, optionally searchable.

Category: Forms. Docs page: https://rime.mjsons.net/components/select

## Installation

```bash
npx shadcn@latest add KezLahd/rime/select
```

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.

## Usage

```tsx
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>
```

## Props: Select

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `options` (required) | `ReadonlyArray<SelectOption<V>>` |  |  |
| `value` (required) | `V \| null` |  |  |
| `onChange` (required) | `(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. |

## Examples

### Open

inline renders the list open under its trigger, in place: how it looks when someone opens it.

```tsx
<Select inline aria-label="Topic" value={topic} onChange={setTopic} options={TOPICS} />
```

### Field appearance

```tsx
<Select value={value} onChange={setValue} placeholder="Choose a topic" options={TOPICS} />
```

### Chip appearance (filters)

active marks a chip whose filter is applied; count shows the rows each option would leave.

```tsx
<Select
  appearance="chip"
  aria-label="Status"
  value={status}
  onChange={setStatus}
  active={status !== "all"}
  options={[
    { value: "all", label: "All statuses" },
    { value: "sent", label: "Sent", count: 2 },
    { value: "paid", label: "Paid", count: 5 },
  ]}
/>
```

### Searchable, grouped

```tsx
<Select searchable value={project} onChange={setProject} placeholder="Choose a project" options={PROJECTS} />
```

### Invalid and disabled

```tsx
<Select invalid … />
<Select disabled … />
```

## Theme tokens

Per-component tokens. Undeclared by default: the default below is the fallback the CSS uses. Set any of them at :root or on a wrapper to restyle every instance.

| Token | Default |
| --- | --- |
| `--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: `--blur-recess`, `--border-w`, `--border-w-field`, `--brand`, `--brand-deep`, `--chip-active-fill`, `--chip-active-fill-hover`, `--chip-active-shadow`, `--control-h-sm`, `--danger-base`, `--danger-field-fill`, `--density`, `--dur`, `--dur-fast`, `--ease`, `--ease-out`, `--field-h-md`, `--field-h-sm`, `--focus-color`, `--focus-halo`, `--focus-ring`, `--font-body`, `--font-mono`, `--ink-body`, `--ink-brand`, `--ink-disabled`, `--ink-muted`, `--ink-placeholder`, `--ink-secondary`, `--line-input`, `--line-soft`, `--popover-blur`, `--popover-border`, `--popover-shadow`, `--popover-surface`, `--r-2xs`, `--r-lg`, `--r-md`, `--r-sm`, `--r-xs`, `--recess-fill`, `--recess-fill-hover`, `--rgb-brand`, `--rgb-brand-deep`, `--rgb-contact`, `--rgb-danger`, `--rgb-white`, `--shadow-input`, `--shadow-recess`, `--surface-solid`, `--surface-sunken`, `--text-body`, `--text-caption`, `--text-input`, `--text-micro`, `--text-small`, `--tracking-label`, `--wash-active`, `--wash-strong`, `--wash-subtle`, `--z-popover`.

## 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

- 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.

Related: https://rime.mjsons.net/components/text-input, https://rime.mjsons.net/components/filter-bar, https://rime.mjsons.net/components/radio-group, https://rime.mjsons.net/components/menu
