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

  • Canada
  • France
  • Germany
  • Japan
Show 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 of role="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.

  • Ada LovelaceEngineering
  • Grace HopperResearch
  • Katherine JohnsonOn leave
  • Margaret HamiltonFlight software
Show 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

  • Ana LimaALAna Lima
  • Whole team
Show 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

  • Ana Lima
  • Ben Ortiz
  • Cy Park
Show 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.

  • Canada
  • France
  • Japan
Ana Lima
  • Ben Ortiz
Show 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.

  • January
  • February
  • March
  • April
  • May
  • June
  • July
  • August
  • September
  • October
  • November
  • December
Show code
<div class="w-64 overflow-hidden rounded-md border border-neutral p-4">
<Combobox label="Month" options={['January', 'February', …]} />
</div>

Sizes

  • One
  • Two
  • Three
  • One
  • Two
  • Three
  • One
  • Two
  • Three
  • One
  • Two
  • Three
Show 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.
  • Canada
  • France
  • Canada
  • France
Show 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 with aria-expanded, aria-controls, aria-autocomplete="list", and aria-activedescendant on the highlighted option; role="listbox" / role="option" with aria-selected on the dropdown; disabled options carry aria-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 options short 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 Select instead.
  • Don’t rely on disabled to 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.