Combobox
A text input with a styled, filterable dropdown of suggestions, anchored under the input in the top layer.
Last updated
Uses JavaScript
Wires a real ARIA combobox: typing filters the list and highlights the closest match (prefers a prefix match); arrow keys move the highlight and wrap, Enter or a click commits it into the input. Free-typing is always allowed.
Without JS: The listbox is a closed popover and never appears — the field still works as a plain text input, free-typed and submitted under its `name`.
Show codeHide code
<Combobox label="Country" options={['Canada', 'France', 'Germany', 'Japan']} />Add
npx @ava-ui/apogee add combobox
Pulls in Field (label, hint, error scaffold) and utils. Uses
@lucide/astro internally (ChevronDown) for the caret — no icon import
needed in your usage.
Usage
---
import Combobox from '@/components/apogee/Combobox/Combobox.astro'
---
<Combobox label="Country" options={['Canada', 'France', 'Germany', 'Japan']} />
<Combobox label="Assignee" name="assignee" options={[
{ value: 'u1', label: 'Ada Lovelace', description: 'Engineering' },
{ value: 'u2', label: 'Grace Hopper', description: 'Research', disabled: true },
]} />
Anatomy
- Label / hint / error — from
Field. - Input — the visible text field,
role="combobox". - Caret — optional chevron icon (toggle with
caret). - Listbox — a
role="listbox"popover ofrole="option"suggestions, anchored under the input and as wide as it, filtered as you type; scrolls past 15rem.
API
@composable — exposed to Content Editors in AVA. Any other attribute passes
through to the input (e.g. placeholder, disabled, name).
| Prop | Type | Default | Description |
|---|---|---|---|
id |
string |
auto-generated | Input id; drives the listbox id (${id}-list) and Field wiring. |
label |
string |
— | Field label. |
hint |
string |
— | Helper text (hidden when error is set). |
error |
string |
— | Error text; sets aria-invalid and renders via role="alert". |
options |
(string | { value, label?, description?, disabled?, icon?, avatar?, avatarName? })[] |
— | Suggestions. A string is both value and label; an object can carry a different value, a description line, disabled, and media before the label (a Lucide component, or an image URL with avatarName for the fallback). Required. |
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 input required (native + Field’s * indicator). |
caret |
boolean |
true |
Shows the chevron caret. Set false to hide it. |
multiple |
boolean |
false |
Picks become removable chips inside the field; each posts name=value through a hidden input, chosen options leave the list, Backspace on an empty input removes the last chip. |
name |
string |
— | Form field name (required with multiple). |
value |
string | string[] |
— | Initial text (single), or initial chips as option values (multiple). |
maxItems |
number |
— | Stops accepting picks at this count. |
class |
string |
— | Extra classes, passed to the Field wrapper. |
Rich options
Objects give each suggestion its own value (sent in the select event), a
description line, and a disabled state the arrow keys skip.
Show codeHide code
<Combobox label="Assignee" name="assignee" options={[
{ value: 'u1', label: 'Ada Lovelace', description: 'Engineering' },
{ value: 'u2', label: 'Grace Hopper', description: 'Research' },
{ value: 'u3', label: 'Katherine Johnson', description: 'On leave', disabled: true },
{ value: 'u4', label: 'Margaret Hamilton', description: 'Flight software' },
]} />Icons and avatars
Show codeHide code
<Combobox label="Mention" name="mention" options={[
{ value: 'u1', label: 'Ana Lima', avatar: 'https://i.pravatar.cc/48?img=5', avatarName: 'Ana Lima' },
{ value: 'team', label: 'Whole team', icon: Zap },
]} />Multiple
Show codeHide code
<form>
<Combobox label="Reviewers" name="reviewers" multiple placeholder="Add a reviewer" options={[
{ value: 'ana', label: 'Ana Lima' }, { value: 'ben', label: 'Ben Ortiz' }, { value: 'cy', label: 'Cy Park' },
]} />
</form>Without JS the typing field posts free text under name.
Show codeHide code
<Combobox label="Country" name="country" value="France" options={['Canada', 'France', 'Japan']} />
<form><Combobox label="Owners" name="owners" multiple required value={['ana']} options={[{ value: 'ana', label: 'Ana Lima' }, { value: 'ben', label: 'Ben Ortiz' }]} /></form>Long list and clipping
The listbox scrolls past 15rem and sits in the top layer, so an
overflow: hidden ancestor cannot cut it off.
Show codeHide code
<div class="w-64 overflow-hidden rounded-md border border-neutral p-4">
<Combobox label="Month" options={['January', 'February', …]} />
</div>Sizes
Show codeHide code
<Combobox label="Small" size="small" options={['One', 'Two', 'Three']} />
<Combobox label="Medium" options={['One', 'Two', 'Three']} />
<Combobox label="Large" size="large" options={['One', 'Two', 'Three']} />
<Combobox label="Extra large" size="xlarge" options={['One', 'Two', 'Three']} />States
- Default — closed, empty or pre-filled input.
- Open — focusing or typing in the input opens the listbox; the closest
match is highlighted (
aria-selected+aria-activedescendant). - Error — critical border,
aria-invalid="true", error text replaces the hint. - Disabled — via passthrough
disabled; dimmed, not interactive. - No JS — the listbox never opens; the input is a plain text field.
Pick a country from the list.
Show codeHide code
<Combobox label="Country" options={['Canada', 'France']} error="Pick a country from the list." />
<Combobox label="Country" options={['Canada', 'France']} disabled value="Canada" />Examples
Without the caret:
<Combobox label="Tag" options={['bug', 'feature', 'docs']} caret={false} />
Required, wired into a form with a submit name:
<Combobox label="Country" name="country" required options={['Canada', 'France', 'Germany']} />
Events
Committing an option (Enter or click) writes its label into the input and
dispatches native input and change events plus apogee:combobox-select
(detail: { value, label }, bubbles) on the root, so forms and listeners see
the selection. The input submits the label; read value from the event when
the two differ.
Accessibility
| Key | Action |
|---|---|
↓ |
Opens the list if closed, then moves the highlight down; wraps. |
↑ |
Moves the highlight up; wraps. |
Enter |
Commits the highlighted option. |
Esc |
Closes the list. |
Backspace |
Removes the last chip when the input is empty (multiple only). |
Tab |
Moves focus out and closes the list. |
- Full ARIA combobox pattern:
role="combobox"on the input, wired witharia-expanded,aria-controls,aria-autocomplete="list", andaria-activedescendanton the highlighted option;role="listbox"/role="option"witharia-selectedon the dropdown; disabled options carryaria-disabled. - Clicking an option commits it without losing input focus.
- Focus leaving the whole widget (tab away, click elsewhere) closes the list.
- No JS: the listbox is a closed popover and never opens — the input
still works as a plain, free-typed text field that submits under its
name.
Tokens
| Token | Used for |
|---|---|
--border-neutral |
Input and listbox border |
--background-page |
Input background |
--background-elevation-overlay |
Listbox panel background |
--foreground-neutral |
Input and option text |
--foreground-neutral-faded |
Placeholder text, caret, descriptions |
--foreground-disabled |
Disabled options |
--background-neutral-faded |
Highlighted option background |
--background-neutral-faded-highlighted |
Option hover |
--border-critical |
Error border |
--border-primary |
Focus border |
--duration-fast / --ease-standard |
Listbox open fade/scale |
Do / Don’t
- Do keep
optionsshort and relevant — it’s a suggestion list, not a full dataset; filter server-side before passing in a large set. - Do allow free text — Combobox never forces a value from
options. - Don’t use Combobox when the value must be constrained to the list — use
Selectinstead. - Don’t rely on
disabledto lock in a submitted value — a disabled input doesn’t submit at all.
Content
- Label names the field (“Country”), not the widget (“Combobox”).
- Error copy states what’s wrong or what format is expected.