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

FreeFor trying things out
ProFor teams
BusinessFor organisations
Enterprise
Show 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

North America
Europe
Asia Pacific
Show 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.

Ana LimaALAna Lima
Personal
Personal
Larkspur Inc.
Ana LimaALAna Lima
Show 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' },
]} />

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.

France
Canada
Japan
Germany
Spain
Italy
Show 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.

United States
Canada
Show 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 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 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 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-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 *.
Show 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 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”).