Combobox
A searchable select with single and multi-select modes, chips, create-new, limits and disabled options.
npx shadcn@latest add https://sundaeui.dev/r/combobox.json'use client'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
export interface ComboOption {
value: string
label: string
hint?: string
disabled?: boolean
}
interface Base {
options: ComboOption[]
label: string
placeholder?: string
emptyMessage?: string
/** Offer to create a new option from the query. */
onCreate?: (query: string) => ComboOption | void
}
export type ComboboxProps =
| (Base & { multiple?: false; value: string | null; onChange: (v: string | null) => void; max?: never })
| (Base & { multiple: true; value: string[]; onChange: (v: string[]) => void; max?: number })
/**
* Searchable select. Single mode behaves like a filtered <select>; multiple
* mode collects chips you can remove with Backspace. ARIA 1.2 combobox.
*/
export function Combobox(props: ComboboxProps) {
const { options, label, placeholder = 'Search…', emptyMessage = 'No matches.', onCreate } = props
const id = useId()
const [open, setOpen] = useState(false)
const [q, setQ] = useState('')
const [active, setActive] = useState(0)
const [extra, setExtra] = useState<ComboOption[]>([])
const input = useRef<HTMLInputElement>(null)
const root = useRef<HTMLDivElement>(null)
const list = useRef<HTMLUListElement>(null)
const all = useMemo(() => [...options, ...extra], [options, extra])
const selected = props.multiple ? props.value : props.value ? [props.value] : []
const byValue = (v: string) => all.find((o) => o.value === v)
const filtered = useMemo(() => {
const n = q.trim().toLowerCase()
return n ? all.filter((o) => o.label.toLowerCase().includes(n) || o.hint?.toLowerCase().includes(n)) : all
}, [all, q])
const canCreate = !!onCreate && q.trim() && !all.some((o) => o.label.toLowerCase() === q.trim().toLowerCase())
const items = canCreate ? [...filtered, { value: '__create', label: `Create “${q.trim()}”` }] : filtered
const atMax = props.multiple && props.max !== undefined && props.value.length >= props.max
useEffect(() => setActive(0), [q])
useEffect(() => {
if (!open) return
const off = (e: PointerEvent) => !root.current?.contains(e.target as Node) && setOpen(false)
document.addEventListener('pointerdown', off)
return () => document.removeEventListener('pointerdown', off)
}, [open])
useEffect(() => {
list.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' })
}, [active])
const choose = (o: ComboOption) => {
if (o.disabled) return
let opt = o
if (o.value === '__create') {
const created = onCreate!(q.trim()) || { value: q.trim().toLowerCase().replace(/\s+/g, '-'), label: q.trim() }
setExtra((x) => [...x, created])
opt = created
}
if (props.multiple) {
const has = props.value.includes(opt.value)
if (!has && atMax) return
props.onChange(has ? props.value.filter((v) => v !== opt.value) : [...props.value, opt.value])
setQ('')
input.current?.focus()
} else {
props.onChange(opt.value)
setQ('')
setOpen(false)
}
}
const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!open) setOpen(true)
else setActive((a) => Math.min(items.length - 1, a + 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((a) => Math.max(0, a - 1))
} else if (e.key === 'Enter' && open && items[active]) {
e.preventDefault()
choose(items[active])
} else if (e.key === 'Escape') {
if (q) setQ('')
else setOpen(false)
} else if (e.key === 'Backspace' && !q && props.multiple && props.value.length) {
props.onChange(props.value.slice(0, -1))
}
}
const single = !props.multiple && props.value ? byValue(props.value) : null
return (
<div ref={root} className="relative w-full max-w-sm text-zinc-900 dark:text-zinc-100">
<label htmlFor={`${id}-i`} id={`${id}-l`} className="mb-1.5 block text-[12.5px] font-medium text-zinc-600 dark:text-zinc-400">
{label}
</label>
<div
onClick={() => {
input.current?.focus()
setOpen(true)
}}
className="flex min-h-10 w-full cursor-text flex-wrap items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-2 py-1.5 transition focus-within:border-zinc-900 focus-within:ring-4 focus-within:ring-zinc-900/5 dark:border-zinc-800 dark:bg-zinc-950 dark:focus-within:border-zinc-300"
>
{props.multiple &&
props.value.map((v) => (
<span key={v} className="inline-flex h-7 items-center gap-1 rounded-md bg-zinc-100 pl-2 pr-1 text-[12.5px] font-medium motion-safe:animate-[cb-chip_200ms_cubic-bezier(.34,1.56,.64,1)] dark:bg-zinc-800">
{byValue(v)?.label ?? v}
<button
type="button"
aria-label={`Remove ${byValue(v)?.label ?? v}`}
onClick={(e) => {
e.stopPropagation()
props.onChange(props.value.filter((x) => x !== v))
}}
className="grid size-5 place-items-center rounded text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:hover:bg-zinc-700 dark:hover:text-white"
>
×
</button>
</span>
))}
<input
ref={input}
id={`${id}-i`}
role="combobox"
aria-expanded={open}
aria-controls={`${id}-lb`}
aria-autocomplete="list"
aria-activedescendant={open && items[active] ? `${id}-o-${active}` : undefined}
value={q}
onChange={(e) => {
setQ(e.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={onKey}
placeholder={single ? single.label : selected.length ? '' : placeholder}
className={`h-7 min-w-[6rem] flex-1 bg-transparent px-1 text-[14px] outline-none ${single ? 'placeholder:text-zinc-900 dark:placeholder:text-zinc-100' : 'placeholder:text-zinc-400'}`}
/>
<svg viewBox="0 0 16 16" className={`mr-1 size-3.5 shrink-0 text-zinc-400 transition-transform ${open ? 'rotate-180' : ''}`} aria-hidden>
<path d="m4 6 4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
{open && (
<ul
ref={list}
id={`${id}-lb`}
role="listbox"
aria-labelledby={`${id}-l`}
aria-multiselectable={props.multiple || undefined}
className="absolute left-0 right-0 top-full z-20 mt-1.5 max-h-64 overflow-auto rounded-xl border border-zinc-200 bg-white p-1 shadow-[0_20px_50px_-20px_rgb(0_0_0/0.3)] motion-safe:animate-[cb-in_160ms_cubic-bezier(.22,1,.36,1)] dark:border-zinc-800 dark:bg-zinc-950"
>
{items.length === 0 && <li className="px-3 py-6 text-center text-[13px] text-zinc-500">{emptyMessage}</li>}
{items.map((o, i) => {
const on = selected.includes(o.value)
const dis = o.disabled || (!on && atMax && o.value !== '__create')
return (
<li
key={o.value}
id={`${id}-o-${i}`}
role="option"
aria-selected={on}
aria-disabled={dis || undefined}
data-active={i === active}
onPointerMove={() => setActive(i)}
onPointerDown={(e) => e.preventDefault()}
onClick={() => !dis && choose(o)}
className={`flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13.5px] ${i === active ? 'bg-zinc-100 dark:bg-zinc-900' : ''} ${dis ? 'cursor-not-allowed opacity-40' : ''} ${o.value === '__create' ? 'text-zinc-600 dark:text-zinc-400' : ''}`}
>
<span aria-hidden className={`grid size-4 shrink-0 place-items-center rounded ${props.multiple ? 'border border-zinc-300 dark:border-zinc-700' : ''} ${on ? (props.multiple ? 'border-zinc-900 bg-zinc-900 text-white dark:border-white dark:bg-white dark:text-zinc-900' : '') : ''}`}>
{on && (
<svg viewBox="0 0 16 16" className="size-3" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="m3.5 8.5 3 3 6-6.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
{o.value === '__create' && '+'}
</span>
<span className="flex-1 truncate">{o.label}</span>
{o.hint && <span className="text-[12px] text-zinc-400">{o.hint}</span>}
</li>
)
})}
</ul>
)}
{props.multiple && props.max && <p className="mt-1.5 text-[12px] text-zinc-500 tabular-nums">{props.value.length} of {props.max} selected</p>}
<style>{`@keyframes cb-in{from{opacity:0;transform:translateY(-4px)}}@keyframes cb-chip{from{transform:scale(.8);opacity:0}}`}</style>
</div>
)
}Overview
One component for the two most common selection problems. In single mode it is a select you can type into: the current value shows as the placeholder, the list filters by label and hint, and picking an option closes the list. In multiple mode, choices become chips. Backspace on an empty input removes the last one, and an optional max greys out the rest.
Pass onCreate and the list offers “Create ‘…’” for anything that doesn’t exist yet, which suits tags and labels. Disabled options stay visible but can’t be picked, and the list keeps the active option in view while you arrow through it.
Use it instead of a native <select> when there are more than about ten options, or when people know what they want and would rather type it.
When to use
- Collecting input where the control itself should guide people.
When not to use
- Cases a plain native input already serves well.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/combobox.jsonnpx shadcn@latest add https://sundaeui.dev/r/combobox.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/combobox.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/combobox.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy combobox.tsx into components/sundae/.
- Make sure Tailwind scans that folder (v4 detects it automatically; v3 needs it in `content`).
- Import the component and use it as shown below.
Usage
import { Combobox } from '@/components/sundae/combobox'
<Combobox label="Time zone" options={zones} value={tz} onChange={setTz} />
<Combobox
label="Labels"
multiple
max={4}
options={labels}
value={selected}
onChange={setSelected}
onCreate={(q) => ({ value: slugify(q), label: q })}
/>Props
| Prop | Type | Default |
|---|---|---|
options*{ value, label, hint?, disabled? }. | ComboOption[] | — |
label*Visible label. | string | — |
multipleSwitches value and onChange to string[]. | boolean | — |
value / onChange*Controlled selection. | string | null | string[] | — |
maxMaximum number of selections (multiple only). | number | — |
onCreateEnables creating options from the query. | (query) => ComboOption | void | — |
placeholder / emptyMessageEmpty input and no-results text. | string | — |
Accessibility
- Follows the ARIA 1.2 combobox pattern: role="combobox", aria-controls, aria-activedescendant and a role="listbox" with aria-selected options.
- Multi-select chips have labelled remove buttons, and Backspace removes the last chip.
- Escape clears the query first, then closes the list.
Customization
- Restyle the zinc classes. The chip animation can be removed from cb-chip.
- For async search, filter on the server and pass the results as options.
FAQ
- Is Combobox free?
- Yes. Combobox is free for personal and commercial projects, with full source on this page.
- How do I install Combobox?
- Run npx shadcn@latest add https://sundaeui.dev/r/combobox.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Combobox support keyboard and screen readers?
- Follows the ARIA 1.2 combobox pattern: role="combobox", aria-controls, aria-activedescendant and a role="listbox" with aria-selected options. Multi-select chips have labelled remove buttons, and Backspace removes the last chip.
- Does Combobox respect reduced motion?
- Yes. With prefers-reduced-motion set, decorative motion is removed and state changes are instant.
More like this
All forms →Date Picker
Forms · UnderstatedRange Slider
Forms · UnderstatedOTP Field
Forms · Refined- Pro
Onboarding Flow
Forms · Refined Tag Field
Forms · UnderstatedJelly Switch
Forms · Playful