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.
Last updated
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.
Show codeHide code
<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
npx @ava-ui/apogee add select
Pulls in Field and utils. Uses @lucide/astro for the chevron and the
checkmark.
Usage
---
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
Fieldwrapper: label, hint/error text. <select>— the native control; pass<option>s (and<optgroup>s) in the default slot. Withoptionsit 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 —
startslot icon inside the control;endslot 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).
Show codeHide code
<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
Show codeHide code
<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.
Show codeHide code
<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.
Show codeHide code
<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.
Show codeHide code
<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
Show codeHide code
<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:
Show codeHide code
<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
Show codeHide code
<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-visiblering (--border-primary). - Disabled — dimmed,
cursor-not-allowed. - Error —
aria-invalid="true", border switches to the critical colour,Fieldshows the error text withrole="alert". - Required — the label shows a
*.
Choose a plan
Show codeHide code
<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:
<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>):
<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 arole="listbox"ofrole="option"s witharia-selected(andaria-multiselectablewhenmultiple). 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. Arequiredfield that submits empty focuses the trigger, marks itaria-invalidand shows the browser’s message as arole="alert". labelrenders a real<label for>viaField.hint/errorare wired to the control througharia-describedby;erroralso setsaria-invalidand announces viarole="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
optionswhen choices need a description, an icon-sized layout or a consistent look across platforms. - Do set a
placeholderwhen nothing should be pre-selected. - Don’t use Select for 2–5 always-visible options —
SegmentedControlorRadioshows every choice at once.
Content
labelis a short noun naming the field (“Country”, not “Select your country”).hintis one short clause, no ending period unless it’s a full sentence.errorstates the problem and, where possible, the fix (“Choose a country”).