A searchable single-choice picker with a clear action and keyboard selection.
pnpm dlx shadcn@latest add @latheui/comboboxFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-reactAdd the Lathe items this component imports.
hooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { CheckIcon, ChevronsUpDownIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
export type ComboboxOption = {
value: string
label: string
disabled?: boolean
}
export type ComboboxProps = {
options: ComboboxOption[]
label: string
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
/** Controlled open state of the list. */
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
placeholder?: string
disabled?: boolean
name?: string
className?: string
}
export function Combobox({
options,
label,
value: valueProp,
defaultValue = "",
onValueChange,
open: openProp,
defaultOpen = false,
onOpenChange,
placeholder = "Search options…",
disabled,
name,
className,
}: ComboboxProps) {
const [value, setValue] = useControllableState({
value: valueProp,
defaultValue,
onChange: onValueChange,
})
const [open, setOpen] = useControllableState({
value: openProp,
defaultValue: defaultOpen,
onChange: onOpenChange,
})
// null displays the selected label and shows every option; text represents an active search.
const [query, setQuery] = React.useState<string | null>(null)
const [active, setActive] = React.useState(0)
const id = React.useId()
const input = React.useRef<HTMLInputElement>(null)
const list = React.useRef<HTMLUListElement>(null)
const marker = React.useRef<HTMLLIElement>(null)
// A press inside the combobox. A tap on a phone blurs the input before the click lands,
// so blurs during a press must not close the list.
const press = React.useRef<string | null>(null)
const root = React.useRef<HTMLDivElement>(null)
// A press outside closes the list, which covers a blur skipped during a touch press.
React.useEffect(() => {
if (!open) return
const outside = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) {
setOpen(false)
setQuery(null)
}
}
document.addEventListener("pointerdown", outside)
return () => document.removeEventListener("pointerdown", outside)
}, [open, setOpen])
const found = options.filter((option) =>
option.label.toLowerCase().includes((query ?? "").toLowerCase())
)
const enabled = found.filter((option) => !option.disabled)
const current = enabled[Math.min(active, Math.max(0, enabled.length - 1))]
const selected = options.find((option) => option.value === value)
function openMenu() {
if (disabled) return
setQuery(null)
setActive(
Math.max(
0,
options
.filter((option) => !option.disabled)
.findIndex((option) => option.value === value)
)
)
setOpen(true)
}
function closeMenu() {
setOpen(false)
setQuery(null)
}
function choose(next: string) {
setValue(next)
setQuery(null)
if (press.current === "touch") {
// On a phone, keeping focus would hold the keyboard open over the page.
input.current?.blur()
} else {
// Focus first: a focus event may open the list, so close it after focus is restored.
input.current?.focus()
}
setOpen(false)
}
React.useLayoutEffect(() => {
const mark = marker.current
const node = current
? list.current?.querySelector<HTMLElement>(
'[data-value="' + CSS.escape(current.value) + '"]'
)
: null
if (mark) {
// The highlight is one element that slides to the active option. It is placed without
// a transition when it first appears, so it never travels in from the top of the list.
const appearing = mark.style.opacity !== "1"
mark.style.transition = appearing || !node ? "none" : ""
mark.style.opacity = node ? "1" : "0"
if (node) {
mark.style.transform = "translateY(" + node.offsetTop + "px)"
mark.style.height = node.offsetHeight + "px"
}
}
const viewport = list.current?.parentElement
if (!open || !node || !list.current || !viewport) return
const top = list.current.offsetTop + node.offsetTop
if (top < viewport.scrollTop) viewport.scrollTop = top
else if (
top + node.offsetHeight >
viewport.scrollTop + viewport.clientHeight
)
viewport.scrollTop = top + node.offsetHeight - viewport.clientHeight
}, [open, current, found.length])
return (
<div
ref={root}
className={cn("relative w-full space-y-1.5", className)}
onPointerDownCapture={(event) => {
press.current = event.pointerType
const release = () => {
// After the click that follows the release.
window.setTimeout(() => (press.current = null))
window.removeEventListener("pointerup", release, true)
window.removeEventListener("pointercancel", release, true)
}
window.addEventListener("pointerup", release, true)
window.addEventListener("pointercancel", release, true)
}}
onBlur={(event) => {
if (press.current === "touch") return
if (!event.currentTarget.contains(event.relatedTarget)) closeMenu()
}}
>
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
<div
className={cn(
"flex items-center rounded-md border border-input bg-background focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20",
disabled && "opacity-50"
)}
>
<input
ref={input}
id={id}
role="combobox"
aria-autocomplete="list"
aria-expanded={open && !disabled}
aria-controls={id + "-list"}
aria-activedescendant={
open && !disabled && current
? id + "-option-" + options.indexOf(current)
: undefined
}
disabled={disabled}
autoComplete="off"
placeholder={placeholder}
value={query ?? selected?.label ?? ""}
onFocus={() => {
openMenu()
input.current?.select()
}}
onClick={() => {
if (!open) openMenu()
if (query === null && selected) input.current?.select()
}}
onChange={(event) => {
setQuery(event.target.value)
setOpen(true)
setActive(0)
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault()
if (!open) {
openMenu()
return
}
setActive(
Math.max(
0,
Math.min(
enabled.length - 1,
active + (event.key === "ArrowDown" ? 1 : -1)
)
)
)
} else if (open && (event.key === "Home" || event.key === "End")) {
event.preventDefault()
setActive(
event.key === "Home" ? 0 : Math.max(0, enabled.length - 1)
)
} else if (event.key === "Enter" && open) {
event.preventDefault()
if (current) choose(current.value)
} else if (event.key === "Escape" && open) {
event.preventDefault()
event.stopPropagation()
closeMenu()
} else if (event.key === "Tab") closeMenu()
}}
className="h-9 min-w-0 flex-1 bg-transparent px-3 text-base outline-none md:text-sm"
/>
{value && (
<button
type="button"
disabled={disabled}
aria-label={"Clear " + label}
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
choose("")
setActive(0)
setOpen(true)
}}
className="m-1 grid size-7 cursor-pointer place-items-center rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<XIcon aria-hidden="true" className="size-3.5" />
</button>
)}
<button
type="button"
tabIndex={-1}
disabled={disabled}
aria-label={(open ? "Hide " : "Show ") + label + " options"}
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
if (open) closeMenu()
else {
input.current?.focus()
openMenu()
input.current?.select()
}
}}
className="mr-1 grid size-7 shrink-0 cursor-pointer place-items-center rounded-sm text-muted-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronsUpDownIcon aria-hidden="true" className="size-4" />
</button>
</div>
{name && (
<input type="hidden" name={name} value={value} disabled={disabled} />
)}
{open && !disabled && (
<div className="absolute top-full z-40 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg">
<ul
ref={list}
id={id + "-list"}
role="listbox"
aria-label={label}
className="relative"
>
<li
ref={marker}
role="presentation"
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 rounded-md bg-accent opacity-0 transition-[transform,height] duration-100 ease-out motion-reduce:transition-none"
/>
{found.map((option) => (
<li
key={option.value}
id={id + "-option-" + options.indexOf(option)}
data-value={option.value}
role="option"
aria-selected={value === option.value}
aria-disabled={option.disabled || undefined}
onPointerDown={(event) => event.preventDefault()}
onPointerMove={() => {
if (!option.disabled) setActive(enabled.indexOf(option))
}}
onClick={() => {
if (!option.disabled) choose(option.value)
}}
className={cn(
"relative flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm",
option.disabled && "cursor-not-allowed opacity-40"
)}
>
{option.label}
{value === option.value && (
<CheckIcon aria-hidden="true" className="size-4" />
)}
</li>
))}
</ul>
{!found.length && (
<p role="status" className="p-3 text-sm text-muted-foreground">
No matching options.
</p>
)}
</div>
)}
</div>
)
}Depends on
| Prop | Type | Default | Description |
|---|---|---|---|
| options* (required) | ComboboxOption[] | — | Unique values, searchable labels and optional disabled states. |
| label* (required) | string | — | Visible label and listbox name. |
| value / defaultValue | string | — | Controlled or initial selected value. |
| onValueChange | (value: string) => void | — | Selection callback; an empty string clears selection. |
| open / defaultOpen | boolean | — | Controlled or initial open state of the list. |
| onOpenChange | (open: boolean) => void | — | Called when the list opens or closes. |
| name / disabled | string / boolean | — | Form field name and disabled state. |
Combine the control with your own layout. Semantic colors follow the active theme.
import { Combobox } from "@/components/ui/combobox"
export default function Demo(){return <Combobox className="max-w-xs" label="Assign to" options={[{value:"ada",label:"Ada Chen"},{value:"sam",label:"Sam Rivera"},{value:"lee",label:"Lee Morgan"},{value:"archived",label:"Archived member",disabled:true}]}/>}