# Select

> A labeled, native <select> — full keyboard and mobile picker support for free — or a custom listbox with rich options over the same form control.

URL: https://apogee.avaui.dev/docs/select
Last updated: 2026-10-16

A labeled, native <select> — full keyboard and mobile picker support for free — or a custom listbox with rich options over the same form control.

Last updated October 16, 2026

Uses JavaScript

Only with `options`: hides the native `<select>` and opens a custom listbox (arrows, Home/End, type-ahead, Enter/Space, Escape; multiple with checkmarks) that writes the choice back into that `<select>`.

**Without JS:** The native select shows and works on its own; the custom listbox is never rendered.

```astro
<Select label="Country" name="country">
<option value="">Select a country</option>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="mx">Mexico</option>
</Select>
```

## Add

```sh
npx @ava-ui/apogee add select
```

Pulls in `Field` and `utils`. Uses `@lucide/astro` for the chevron and the checkmark.

## Usage

```astro
---
import Select from '@/components/apogee/Select/Select.astro'
---
<Select label="Country" name="country">
  <option value="us">United States</option>
  <option value="ca">Canada</option>
</Select>

<Select label="Plan" name="plan" placeholder="Pick a plan" options={[
  { value: 'free', label: 'Free', description: 'For trying things out' },
  { value: 'pro', label: 'Pro', description: 'For teams' },
]} />
```

## Anatomy

-   **Field** — the `Field` wrapper: label, hint/error text.
-   **`<select>`** — the native control; pass `<option>`s (and `<optgroup>`s) in the default slot. With `options` it is generated from the array and hidden while JS runs, but it is still the form control.
-   **Trigger** (custom mode) — a field-styled `<button popovertarget>` showing the chosen label or the placeholder.
-   **Listbox** (custom mode) — a `role="listbox"` popover anchored under the trigger, as wide as it, scrolling past 15rem.
-   **Start / end** — `start` slot icon inside the control; `end` slot replaces the decorative chevron.

## API

`@composable` — exposed to Content Editors in AVA. Any other native `<select>` attribute (e.g. `name`, `autocomplete`) passes through to the `<select>` itself.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | auto-generated | Ties the `Field` label to the `<select>`. |
| `label` | `string` | — | Field label. |
| `hint` | `string` | — | Helper text below the control (hidden when `error` is set). |
| `error` | `string` | — | Error text; sets `aria-invalid` and switches the border to the critical colour. |
| `options` | `{ value, label, description?, disabled? }[]` | — | Renders the custom listbox (JS) over a generated native `<select>`. |
| `options[].icon` / `avatar` / `avatarName` | component / `string` | — | Media before the label: a Lucide component, or an image URL (24px avatar, initials fallback from `avatarName`). The trigger shows the chosen option’s media. |
| `placeholder` | `string` | — | Empty first choice; shown faded until a value is picked. Disabled when `required`. |
| `value` | `string \| string[]` | — | Initial value(s). `selected` on an `<option>` works too. |
| `multiple` | `boolean` | `false` | Multi-select: native listbox, or checkmarks in the custom listbox. |
| `search` | `boolean` | `false` | A search box at the top of the custom listbox; filters options by label. |
| `selectAll` | `boolean` | `false` | A “Select all” / “Clear” footer (multiple only). |
| `countLabel` | `string` | `'{count} selected'` | Trigger text when more than one option is chosen. |
| `emptyText` | `string` | `'No matches'` | Shown when the search hides every option. |
| `searchPlaceholder` | `string` | `'Search…'` | Placeholder and accessible name of the search box. |
| `selectAllLabel` | `string` | `'Select all'` | Footer button text. |
| `clearLabel` | `string` | `'Clear'` | Footer button text. |
| `disabled` | `boolean` | `false` | Disables the control (and the custom trigger). |
| `size` | `'small' \| 'medium' \| 'large' \| 'xlarge'` | `'medium'` | Height: 28 / 36 / 48 / 56px. |
| `variant` | `'outline' \| 'faded' \| 'ghost' \| 'headless'` | `'outline'` | The box style: bordered, tinted, bare, or no box at all. |
| `hasError` | `boolean` | `false` | Marks the field invalid (`aria-invalid`, critical border) without an `error` message. |
| `required` | `boolean` | `false` | Marks the field required (native `required` + a visible `*` on the label). |
| `class` | `string` | — | Extra classes on the `Field` wrapper. |

Slots: `start` (icon inside the control), `end` (replaces the chevron).

## Custom listbox

Pass `options`. The native `<select>` stays the form control, so the value submits with the form, `change` fires on it, and `required` still blocks an empty submit (the message shows under the trigger, which takes focus).

```astro
<form>
<Select label="Plan" name="plan" placeholder="Pick a plan" required options={[
  { value: 'free', label: 'Free', description: 'For trying things out' },
  { value: 'pro', label: 'Pro', description: 'For teams' },
  { value: 'business', label: 'Business', description: 'For organisations' },
  { value: 'enterprise', label: 'Enterprise', disabled: true },
]} />
<Button type="submit">Continue</Button>
</form>
```

### Multiple

```astro
<Select label="Regions" name="regions" multiple value={['eu']} options={[
{ value: 'na', label: 'North America' },
{ value: 'eu', label: 'Europe' },
{ value: 'apac', label: 'Asia Pacific' },
]} />
```

## Icons and avatars

Each option can lead with a Lucide component or an image; the trigger repeats the chosen one.

```astro
<Select label="Workspace" name="ws" options={[
{ value: 'personal', label: 'Personal', icon: Zap },
{ value: 'larkspur', label: 'Larkspur Inc.', icon: Building2 },
{ value: 'ana', label: 'Ana Lima', avatar: 'https://i.pravatar.cc/48?img=5', avatarName: 'Ana Lima' },
]} />
```

## Multiple with search and a footer

A multiple select shows a checkbox on each option, counts the choices in the trigger, and can carry a search box and a footer. The native `<select multiple>` still submits.

```astro
<Select label="Countries" name="countries" multiple search selectAll placeholder="Pick some" options={[
{ value: 'fr', label: 'France' }, { value: 'ca', label: 'Canada' }, { value: 'jp', label: 'Japan' },
{ value: 'de', label: 'Germany' }, { value: 'es', label: 'Spain' }, { value: 'it', label: 'Italy' },
]} />
```

## Placeholder and start icon

`placeholder` injects an empty first option and greys the text until a value is chosen. The `start` slot works in both modes.

```astro
<Select label="Country" name="ph-native" placeholder="Select a country">
<Globe slot="start" />
<option value="us">United States</option>
<option value="ca">Canada</option>
</Select>
<Select label="Country" name="ph-custom" placeholder="Select a country" options={[
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
]}>
<Globe slot="start" />
</Select>
```

## Variants

```astro
<Select label="Outline" name="v-outline" variant="outline"><option>Option</option></Select>
<Select label="Faded" name="v-faded" variant="faded"><option>Option</option></Select>
<Select label="Ghost" name="v-ghost" variant="ghost"><option>Option</option></Select>
<Select label="Headless" name="v-headless" variant="headless"><option>Option</option></Select>
```

## Sizes

Every size:

```astro
<Select label="Small" name="size-sm" size="small"><option>Option</option></Select>
<Select label="Medium" name="size-md"><option>Option</option></Select>
<Select label="Large" name="size-lg" size="large"><option>Option</option></Select>
<Select label="Extra large" name="size-xl" size="xlarge"><option>Option</option></Select>
```

## Option groups and disabled options

```astro
<Select label="Timezone" name="tz">
<optgroup label="Europe">
  <option value="lisbon">Lisbon</option>
  <option value="bucharest">Bucharest</option>
</optgroup>
<optgroup label="Americas">
  <option value="toronto">Toronto</option>
  <option value="lima" disabled>Lima (soon)</option>
</optgroup>
</Select>
```

## States

-   **Hover / focus** — a visible `focus-visible` ring (`--border-primary`).
-   **Disabled** — dimmed, `cursor-not-allowed`.
-   **Error** — `aria-invalid="true"`, border switches to the critical colour, `Field` shows the error text with `role="alert"`.
-   **Required** — the label shows a `*`.

```astro
<Select label="Plan" name="state-required" required>
<option value="free">Free</option>
</Select>
<Select label="Plan" name="state-error" error="Choose a plan">
<option value="">Select a plan</option>
</Select>
<Select label="Plan" name="state-disabled" disabled>
<option value="free">Free</option>
</Select>
```

## Examples

With hint text:

```astro
<Select label="Plan" name="plan" hint="You can change this later">
  <option value="free">Free</option>
  <option value="pro">Pro</option>
</Select>
```

Listening for a choice (both modes fire `change` on the `<select>`):

```astro
<script>
  document.querySelector('select[name="plan"]').addEventListener('change', (e) => {
    console.log(e.target.value)
  })
</script>
```

## Accessibility

| Key | Action |
| --- | --- |
| `Tab` | Moves focus to the trigger. In the open list, moves from the search field to the options to the footer buttons, then closes the list. |
| `Shift` `Tab` | Moves back the same way; before the first stop, closes the list. |
| `↑` `↓` | On the trigger, open the list. In the list, move between options; `↑` on the first option returns to the search field. |
| `Home` `End` | First / last option. |
| `Enter` `Space` | On the trigger, open the list. On an option, choose it; a single select then closes. |
| `Enter` | In the search field, chooses the first match. |
| `Esc` | Closes the list and returns focus to the trigger. |
| Letters | Jump to the option whose label starts with the typed text. |

-   Native mode: a real `<select>` — full native keyboard (type-ahead, arrow keys) and mobile picker support for free.
-   Custom mode: a `<button aria-haspopup="listbox">` labelled by the field label and the shown value, and a `role="listbox"` of `role="option"`s with `aria-selected` (and `aria-multiselectable` when `multiple`). Opening focuses the chosen option. An outside click closes the list (native light dismiss) and focus returns to the trigger. Clicking the label focuses the trigger. A `required` field that submits empty focuses the trigger, marks it `aria-invalid` and shows the browser’s message as a `role="alert"`.
-   `label` renders a real `<label for>` via `Field`.
-   `hint` / `error` are wired to the control through `aria-describedby`; `error` also sets `aria-invalid` and announces via `role="alert"`.
-   The chevron and checkmark are decorative (`aria-hidden="true"`).

## Tokens

| Token | Used for |
| --- | --- |
| `--border-neutral` | Control and listbox border |
| `--background-page` | Control fill |
| `--background-elevation-overlay` | Listbox background |
| `--background-neutral-faded` | Selected option fill |
| `--background-neutral-faded-highlighted` | Option hover / focus |
| `--foreground-neutral` | Selected value and option text |
| `--foreground-neutral-faded` | Chevron, placeholder, descriptions |
| `--foreground-disabled` | Disabled options |
| `--border-primary` / `--outline-color-focus` | Focus border / focus ring |
| `--foreground-critical` | Error border (`aria-invalid`) + `Field`’s error text |

## Do / Don’t

-   **Do** use the native mode by default — the platform picker is the best one on phones.
-   **Do** reach for `options` when choices need a description, an icon-sized layout or a consistent look across platforms.
-   **Do** set a `placeholder` when nothing should be pre-selected.
-   **Don’t** use Select for 2–5 always-visible options — `SegmentedControl` or `Radio` shows every choice at once.

## Content

-   `label` is a short noun naming the field (“Country”, not “Select your country”).
-   `hint` is one short clause, no ending period unless it’s a full sentence.
-   `error` states the problem and, where possible, the fix (“Choose a country”).

## Related

[

ComboboxA text input with a styled, filterable dropdown of suggestions, anchored under the input in the top layer.

](https://apogee.avaui.dev/docs/combobox)[

DropdownA menu on the native Popover API — the trigger opens a list of DropdownItems in the top layer, with sections and submenus.

](https://apogee.avaui.dev/docs/dropdown)[

FieldThe labeled-field scaffold — label, control, and hint/error — wired for accessibility.

](https://apogee.avaui.dev/docs/field)
