Combobox
A searchable list for long sets of options: type to filter instead of scrolling.
- Alta Verapaz
- Baja Verapaz
- Chimaltenango
- Chiquimula
- El Progreso
- Escuintla
- Guatemala
- Huehuetenango
- Izabal
- Jalapa
- Jutiapa
- Petén
- Quetzaltenango
- Quiché
- Retalhuleu
- Sacatepéquez
- San Marcos
- Santa Rosa
- Sololá
- Suchitepéquez
- Totonicapán
- Zacapa
Installation
$ pnpm dlx shadcn@latest add @ssntl/comboboxUsage
import { Combobox } from "@/components/ui/combobox"
<Combobox
id="department"
options={[{ value: "peten", label: "Petén" }, …]}
placeholder="Search departments"
onValueChange={setDepartment}
/>- Search over scrolling: past a dozen options, a search field beats a long list.
- Filtering ignores case and accents, so “peten” finds Petén.
- Arrow keys move through matches, Enter picks one, Escape closes and restores the value (ARIA combobox pattern).
- Pair it with a FieldLabel; set emptyText and clearLabel in the user's language.
Code
"use client"
import * as React from "react"
import { Check, Search, X } from "lucide-react"
import { cn } from "@/lib/utils"
export interface ComboboxOption {
value: string
label: string
}
// Search over scrolling: past a dozen options, typing beats scanning. The
// field filters as you type (ignoring case and accents), arrows move through
// the matches, Enter picks one, Escape closes. It follows the ARIA combobox
// pattern, so screen readers announce the list and the active option.
const normalize = (text: string) =>
text
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase()
function Combobox({
options,
value,
defaultValue,
onValueChange,
placeholder,
emptyText = "No matches",
clearLabel = "Clear",
className,
...props
}: Omit<
React.ComponentProps<"input">,
"value" | "defaultValue" | "onChange"
> & {
options: ComboboxOption[]
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
/** Shown when the search has no match. */
emptyText?: string
/** The clear button's accessible name. */
clearLabel?: string
}) {
const listId = React.useId()
const [selected, setSelected] = React.useState(defaultValue ?? "")
const current = value ?? selected
const label = options.find((o) => o.value === current)?.label ?? ""
const [query, setQuery] = React.useState(label)
const [open, setOpen] = React.useState(false)
const [active, setActive] = React.useState(0)
const inputRef = React.useRef<HTMLInputElement>(null)
const matches = React.useMemo(() => {
const q = normalize(query.trim())
if (!q || query === label) return options
return options.filter((o) => normalize(o.label).includes(q))
}, [options, query, label])
const choose = (option: ComboboxOption) => {
setSelected(option.value)
onValueChange?.(option.value)
setQuery(option.label)
setOpen(false)
}
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault()
if (!open) return setOpen(true)
const step = event.key === "ArrowDown" ? 1 : -1
setActive(
(i) => (i + step + matches.length) % Math.max(matches.length, 1)
)
} else if (event.key === "Enter" && open && matches[active]) {
event.preventDefault()
choose(matches[active])
} else if (event.key === "Escape") {
setOpen(false)
setQuery(label)
}
}
return (
<div data-slot="combobox" className={cn("relative", className)}>
<div className="flex h-12 items-center gap-3 rounded-sm border border-input bg-card ps-4 pe-1 transition-[border-color] duration-(--duration-fast) ease-out focus-within:border-foreground focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-ring hover:border-muted-foreground">
<Search
aria-hidden
strokeWidth={1.6}
className="size-5 shrink-0 text-muted-foreground"
/>
<input
ref={inputRef}
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={
open && matches[active] ? `${listId}-${active}` : undefined
}
autoComplete="off"
value={query}
placeholder={placeholder}
onChange={(event) => {
setQuery(event.target.value)
setActive(0)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onBlur={() => {
setOpen(false)
setQuery(label)
}}
onKeyDown={onKeyDown}
className="h-full min-w-0 flex-1 bg-transparent text-body outline-none placeholder:text-muted-foreground"
{...props}
/>
{query && (
<button
type="button"
aria-label={clearLabel}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setQuery("")
setOpen(true)
inputRef.current?.focus()
}}
className="grid size-10 shrink-0 cursor-pointer place-items-center rounded-xs text-muted-foreground transition-[background-color,color] duration-(--duration-fast) ease-out hover:bg-secondary hover:text-foreground"
>
<X aria-hidden strokeWidth={1.6} className="size-4" />
</button>
)}
</div>
<ul
id={listId}
role="listbox"
hidden={!open}
className="absolute inset-x-0 top-full z-10 mt-2 max-h-72 overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-float"
>
{matches.map((option, i) => (
<li
key={option.value}
id={`${listId}-${i}`}
role="option"
aria-selected={option.value === current}
data-active={i === active || undefined}
onMouseDown={(event) => event.preventDefault()}
onMouseMove={() => setActive(i)}
onClick={() => choose(option)}
className="flex min-h-11 cursor-pointer items-center justify-between gap-3 rounded-sm px-3 text-body data-active:bg-secondary"
>
{option.label}
{option.value === current && (
<Check
aria-hidden
strokeWidth={1.6}
className="size-5 shrink-0"
/>
)}
</li>
))}
{matches.length === 0 && (
<li
role="presentation"
className="px-3 py-3 text-caption text-muted-foreground"
>
{emptyText}
</li>
)}
</ul>
</div>
)
}
export { Combobox }