Inputs
Combobox
A text input that filters a list as you type, for choosing one item from many: people, clients, countries, currencies. Matching ignores case and accents, options can carry an icon and a second line, and there's a clear empty state when nothing matches.
AM
AMAda Morenoada@fernhill.example
RTRui Tavaresrui@fernhill.example
LOLena Okaforlena@fernhill.example
JBJonas Brandtjonas@fernhill.example
MLMei LindqvistInvited · pending
function Initials({ children }: { children: string }) { return ( <span className="flex size-5 items-center justify-center rounded-full bg-accent-soft text-[10px] font-semibold text-accent">{children}</span> );} <div className="mx-auto flex h-[340px] w-full max-w-xs flex-col"> <Field label="Assign to"> <Combobox placeholder="Search teammates…" defaultValue="ada" options={[ { value: "ada", label: "Ada Moreno", description: "ada@fernhill.example", icon: <Initials>AM</Initials> }, { value: "rui", label: "Rui Tavares", description: "rui@fernhill.example", icon: <Initials>RT</Initials> }, { value: "lena", label: "Lena Okafor", description: "lena@fernhill.example", icon: <Initials>LO</Initials> }, { value: "jonas", label: "Jonas Brandt", description: "jonas@fernhill.example", icon: <Initials>JB</Initials> }, { value: "mei", label: "Mei Lindqvist", description: "Invited · pending", icon: <Initials>ML</Initials>, disabled: true }, ]} /> </Field></div>Usage
components/ui/combobox.tsx
import { Combobox } from "@/components/ui/combobox";import { Field } from "@/components/ui/field"; const currencies = [ { value: "eur", label: "Euro", description: "EUR · €" }, { value: "usd", label: "US dollar", description: "USD · $" },]; export function Example() { return ( <Field label="Currency"> <Combobox options={currencies} defaultValue="eur" /> </Field> );}Examples
Assignee
AM
AMAda Morenoada@fernhill.example
RTRui Tavaresrui@fernhill.example
LOLena Okaforlena@fernhill.example
JBJonas Brandtjonas@fernhill.example
MLMei LindqvistInvited · pending
function Initials({ children }: { children: string }) { return ( <span className="flex size-5 items-center justify-center rounded-full bg-accent-soft text-[10px] font-semibold text-accent">{children}</span> );} <div className="mx-auto flex h-[340px] w-full max-w-xs flex-col"> <Field label="Assign to"> <Combobox placeholder="Search teammates…" defaultValue="ada" options={[ { value: "ada", label: "Ada Moreno", description: "ada@fernhill.example", icon: <Initials>AM</Initials> }, { value: "rui", label: "Rui Tavares", description: "rui@fernhill.example", icon: <Initials>RT</Initials> }, { value: "lena", label: "Lena Okafor", description: "lena@fernhill.example", icon: <Initials>LO</Initials> }, { value: "jonas", label: "Jonas Brandt", description: "jonas@fernhill.example", icon: <Initials>JB</Initials> }, { value: "mei", label: "Mei Lindqvist", description: "Invited · pending", icon: <Initials>ML</Initials>, disabled: true }, ]} /> </Field></div>Search and empty state
Type “zz” to see the empty state. Matching also checks keywords, so “Deutschland” finds Germany.
Portugal
Germany
Spain
France
Ireland
Netherlands
Türkiye
United Kingdom
<div className="mx-auto flex h-[340px] w-full max-w-xs flex-col"> <Field label="Country"> <Combobox placeholder="Search countries…" emptyMessage="No country found" options={[ { value: "pt", label: "Portugal", keywords: ["PT"] }, { value: "de", label: "Germany", keywords: ["DE", "Deutschland"] }, { value: "es", label: "Spain", keywords: ["ES", "España"] }, { value: "fr", label: "France", keywords: ["FR"] }, { value: "ie", label: "Ireland", keywords: ["IE", "Éire"] }, { value: "nl", label: "Netherlands", keywords: ["NL", "Holland"] }, { value: "tr", label: "Türkiye", keywords: ["TR", "Turkey"] }, { value: "gb", label: "United Kingdom", keywords: ["GB", "UK"] }, ]} /> </Field></div>Controlled
Pass value and onValueChange to drive other UI from the selection. The clear button sets the value to null.
Fernhill StudioLisbon · 12 open invoices
LedgerlyBerlin · 3 open invoices
Brightwater LabsDublin · no open invoices
Oakmere & Co.Leeds · 1 open invoice
Quillon HealthPorto · 5 open invoices
Next invoice: Ledgerly
"use client"; import { useState } from "react";import { Combobox } from "@/components/ui/combobox";import { Field } from "@/components/ui/field"; const clients = [ { value: "fernhill", label: "Fernhill Studio", description: "Lisbon · 12 open invoices" }, { value: "ledgerly", label: "Ledgerly", description: "Berlin · 3 open invoices" }, { value: "brightwater", label: "Brightwater Labs", description: "Dublin · no open invoices" }, { value: "oakmere", label: "Oakmere & Co.", description: "Leeds · 1 open invoice" }, { value: "quillon", label: "Quillon Health", description: "Porto · 5 open invoices" },]; export function ControlledComboboxDemo() { const [client, setClient] = useState<string | null>("ledgerly"); const chosen = clients.find((c) => c.value === client); return ( <div className="mx-auto flex h-[340px] w-full max-w-xs flex-col gap-3"> <Field label="Bill to" hint={chosen ? `Next invoice: ${chosen.label}` : "No client selected"}> <Combobox options={clients} value={client} onValueChange={setClient} placeholder="Search clients…" emptyMessage="No client matches" /> </Field> </div> );}Sizes and states
Euro
Euro
Fernhill rebrand
Pick a project for this time entry.
Ada Moreno
Only admins can change the owner.
<div className="mx-auto grid w-full max-w-xl gap-5 sm:grid-cols-2"> <Field label="Small"> <Combobox size="sm" options={[{ value: "eur", label: "Euro" }]} defaultValue="eur" /> </Field> <Field label="Large"> <Combobox size="lg" options={[{ value: "eur", label: "Euro" }]} defaultValue="eur" /> </Field> <Field label="Project" error="Pick a project for this time entry."> <Combobox options={[{ value: "rebrand", label: "Fernhill rebrand" }]} placeholder="Search projects…" /> </Field> <Field label="Owner" hint="Only admins can change the owner." disabled> <Combobox options={[{ value: "ada", label: "Ada Moreno" }]} defaultValue="ada" /> </Field></div>Props
PropTypeDefaultDescription
optionsrequiredComboboxOption[]default: —{ value, label, description?, icon?, keywords?, disabled? }.value / defaultValuestring | nulldefault: —Controlled or initial selected value.onValueChange(value: string | null) => voiddefault: —Called on select and on clear (null).placeholderstringdefault: "Search…"Shown when nothing is selected.emptyMessageReactNodedefault: "No results"Shown when the search matches nothing.clearablebooleandefault: trueShow a clear button when a value is selected.size"sm" | "md" | "lg"default: "md"Height 32 / 36 / 40px.invalid / disabledbooleandefault: —Set automatically inside a Field with error or disabled.namestringdefault: —Adds a hidden input with the selected value for forms.aria-labelstringdefault: —Needed when there's no Field label.
Accessibility
- Follows the WAI-ARIA combobox pattern: the input has role="combobox", aria-expanded and aria-controls pointing at a role="listbox".
- Focus stays in the input. Down and Up open the list and move the highlight, which is announced through aria-activedescendant; Home and End jump to the ends.
- Enter selects the highlighted option, Escape closes the list (and clears the value when the list is already closed), Tab closes and moves on.
- Options use role="option" with aria-selected; disabled options have aria-disabled and are skipped by the arrow keys.
- The selected option shows a check mark in addition to the highlight color.