Combobox
A searchable single-select built on the ARIA combobox pattern.
Installation
pnpm dlx @dowel-ui/cli add comboboxnpm packages installed: radix-ui.
Accessibility
The input owns role="combobox" with aria-expanded, aria-controls and aria-autocomplete; the list owns role="listbox" and each option role="option" with aria-selected. Focus stays in the input while arrow keys move aria-activedescendant, so typing is never interrupted. Arrow navigation wraps, Home/End jump, Enter selects the active option and Escape closes. With loading set, the listbox is aria-busy, ComboboxLoading is a status and the empty state waits for the answer. The optional clear button is a named button that returns focus to the input.
Props
Combobox
| Prop | Type | Default |
|---|---|---|
allowDeselectChoosing the selected option again clears it, reporting | boolean | false |
defaultOpen | boolean | false |
defaultValue | string | — |
filterOverrides how a search string is matched against an option. | (search: string, haystack: string[]) => boolean | defaultComboboxFilter |
loadingResults are on their way: the listbox is | boolean | false |
onOpenChange | (open: boolean) => void | — |
onSearchChangeCalled as the search text changes, including the reset when it closes. | (search: string) => void | — |
onValueChange | (value: string) => void | — |
open | boolean | — |
searchDebounceMilliseconds of quiet typing before | number | 0 |
shouldFilterSet | boolean | true |
value | string | — |
children | ReactNode | — |
ComboboxTrigger
| Prop | Type | Default |
|---|---|---|
placeholderShown when nothing is selected. | string | "Select…" |
Plus every attribute of <button>.
ComboboxContent
| Prop | Type | Default |
|---|---|---|
labelNames the popover, which carries role="dialog". | string | "Search options" |
Every prop of Radix UI’s Popover.Content, plus className.
ComboboxInput
| Prop | Type | Default |
|---|---|---|
clearableShows a button that clears the search while there is text to clear. | boolean | false |
Plus every attribute of <input> except value, onChange.
ComboboxList
Plus every attribute of <div>.
ComboboxItem
| Prop | Type | Default |
|---|---|---|
value (required)The value reported by onValueChange, and the primary search term. | string | — |
disabled | boolean | — |
keywordsExtra search terms that are not shown, such as synonyms or an id. | string[] | — |
Plus every attribute of <div> except onSelect, children.
ComboboxEmpty
Plus every attribute of <div>.
ComboboxLoading
Plus every attribute of <div>.
Quality
10/10 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — passes
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — passes
- No fixed widths — passes
Source
This is exactly what dowel add combobox writes into your project, with imports rewritten to your own path alias.
"use client";
// Motion from SmoothUI Combobox and SearchableDropdown (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import { Popover as PopoverPrimitive } from "radix-ui";
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
type ComponentPropsWithRef,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import { focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";
/**
* A searchable single-select.
*
* Implements the ARIA combobox pattern directly on a popover rather than
* depending on a command-menu package: the input owns `role="combobox"`, the
* list owns `role="listbox"`, and the active option is tracked with
* `aria-activedescendant` so focus never leaves the input while typing.
*
* Navigation reads the rendered options from the DOM rather than a registry,
* which keeps the active option correct as filtering adds and removes items —
* a registry has to be kept in sync with what is actually on screen, and drifts.
*
* This version is single-select and flat. Multi-select and grouping can be
* added without changing this API.
*/
/** Default match: case-insensitive substring over the value and any keywords. */
export function defaultComboboxFilter(search: string, haystack: string[]): boolean {
const needle = search.trim().toLowerCase();
if (!needle) return true;
return haystack.some((entry) => entry.toLowerCase().includes(needle));
}
interface ComboboxContextValue {
open: boolean;
setOpen: (open: boolean) => void;
search: string;
setSearch: (search: string) => void;
value: string | undefined;
select: (value: string) => void;
activeValue: string | undefined;
setActiveValue: (value: string | undefined) => void;
filter: (search: string, haystack: string[]) => boolean;
listId: string;
inputId: string;
optionId: (value: string) => string;
listRef: React.RefObject<HTMLDivElement | null>;
registerVisible: (value: string, visible: boolean) => void;
visibleCount: number;
loading: boolean;
}
const ComboboxContext = createContext<ComboboxContextValue | null>(null);
function useCombobox(component: string): ComboboxContextValue {
const context = useContext(ComboboxContext);
if (!context) {
throw new Error(`${component} must be rendered inside <Combobox>.`);
}
return context;
}
export interface ComboboxProps {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Overrides how a search string is matched against an option. */
filter?: (search: string, haystack: string[]) => boolean;
/**
* Set `false` when the options are already the results for the search — for
* example when `onSearchChange` fetches them — so they are not filtered twice.
*/
shouldFilter?: boolean;
/** Called as the search text changes, including the reset when it closes. */
onSearchChange?: (search: string) => void;
/** Milliseconds of quiet typing before `onSearchChange` fires. */
searchDebounce?: number;
/**
* Results are on their way: the listbox is `aria-busy`, `ComboboxLoading`
* shows and `ComboboxEmpty` holds back, so "No results" never flashes first.
*/
loading?: boolean;
/** Choosing the selected option again clears it, reporting `""`. */
allowDeselect?: boolean;
children?: ReactNode;
}
/*
* Motion (SmoothUI's Combobox): options rise in, the first few a beat apart,
* and the trigger's chevron turns while open. Keyframes ship with the
* component (ADR 0014); every duration and delay runs on the motion scale.
*/
const ITEM_STAGGER = Array.from(
{ length: 8 },
(_, index) =>
`[data-slot="combobox-item"]:nth-child(${String(index + 2)}){animation-delay:calc(${String((index + 1) * 20)}ms * var(--motion-scale, 1))}`,
).join("");
const ITEM_KEYFRAMES = `
@keyframes dowel-combobox-item-in{from{opacity:0;transform:translateY(0.25rem)}}
[data-slot="combobox-item"]{animation:dowel-combobox-item-in var(--duration-normal) var(--ease-out-quint) backwards}
${ITEM_STAGGER}
`;
export function Combobox({
value: valueProp,
defaultValue,
onValueChange,
open: openProp,
defaultOpen = false,
onOpenChange,
filter: filterProp = defaultComboboxFilter,
shouldFilter = true,
onSearchChange,
searchDebounce = 0,
loading = false,
allowDeselect = false,
children,
}: ComboboxProps) {
const uid = useId();
const filter = shouldFilter ? filterProp : acceptAll;
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
const value = valueProp === undefined ? uncontrolledValue : valueProp;
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const open = openProp === undefined ? uncontrolledOpen : openProp;
const [search, setSearchState] = useState("");
const searchRef = useRef("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const onSearchChangeRef = useRef(onSearchChange);
useEffect(() => {
onSearchChangeRef.current = onSearchChange;
}, [onSearchChange]);
const setSearch = useCallback(
(next: string) => {
if (next === searchRef.current) return;
searchRef.current = next;
setSearchState(next);
clearTimeout(debounceRef.current);
if (searchDebounce <= 0) {
onSearchChangeRef.current?.(next);
return;
}
debounceRef.current = setTimeout(() => {
onSearchChangeRef.current?.(next);
}, searchDebounce);
},
[searchDebounce],
);
useEffect(
() => () => {
clearTimeout(debounceRef.current);
},
[],
);
const [activeValue, setActiveValue] = useState<string | undefined>(undefined);
const listRef = useRef<HTMLDivElement | null>(null);
// Counted rather than listed: the empty state only needs to know whether
// anything survived the filter.
const visibleValues = useRef(new Set<string>());
const [visibleCount, setVisibleCount] = useState(0);
const registerVisible = useCallback((optionValue: string, visible: boolean) => {
const set = visibleValues.current;
const had = set.has(optionValue);
if (visible && !had) set.add(optionValue);
else if (!visible && had) set.delete(optionValue);
else return;
setVisibleCount(set.size);
}, []);
const setOpen = useCallback(
(next: boolean) => {
if (openProp === undefined) setUncontrolledOpen(next);
onOpenChange?.(next);
// Reopening should start from a clean search rather than resuming a
// half-typed query the user has forgotten about.
if (!next) {
setSearch("");
setActiveValue(undefined);
}
},
[openProp, onOpenChange, setSearch],
);
const select = useCallback(
(chosen: string) => {
const next = allowDeselect && chosen === value ? "" : chosen;
if (valueProp === undefined) setUncontrolledValue(next || undefined);
onValueChange?.(next);
setOpen(false);
},
[valueProp, onValueChange, setOpen, allowDeselect, value],
);
const context = useMemo<ComboboxContextValue>(
() => ({
open,
setOpen,
search,
setSearch,
value,
select,
activeValue,
setActiveValue,
filter,
listId: `${uid}-list`,
inputId: `${uid}-input`,
optionId: (optionValue: string) =>
`${uid}-option-${optionValue.replace(/\s+/g, "-").toLowerCase()}`,
listRef,
registerVisible,
visibleCount,
loading,
}),
[
open,
setOpen,
search,
setSearch,
loading,
value,
select,
activeValue,
filter,
uid,
registerVisible,
visibleCount,
],
);
return (
<ComboboxContext.Provider value={context}>
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
{children}
</PopoverPrimitive.Root>
</ComboboxContext.Provider>
);
}
function acceptAll(): boolean {
return true;
}
export interface ComboboxTriggerProps extends ComponentPropsWithRef<"button"> {
/** Shown when nothing is selected. */
placeholder?: string;
}
export function ComboboxTrigger({
className,
placeholder = "Select…",
children,
...props
}: ComboboxTriggerProps) {
const { value, open } = useCombobox("ComboboxTrigger");
return (
<PopoverPrimitive.Trigger asChild>
<button
type="button"
data-slot="combobox-trigger"
aria-expanded={open}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-background",
"px-3 text-sm shadow-xs transition-[border-color,box-shadow] duration-[var(--duration-fast)]",
"focus-visible:border-ring",
"disabled:cursor-not-allowed disabled:opacity-55",
"aria-invalid:border-destructive",
focusRing,
className,
)}
{...props}
>
<span className={cn("truncate", !value && "text-muted-foreground")}>
{children ?? value ?? placeholder}
</span>
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
data-slot="combobox-chevron"
className={cn(
"size-4 shrink-0 opacity-60",
"transition-[rotate] duration-[var(--duration-normal)] ease-[var(--ease-out-quint)]",
open && "rotate-180",
)}
>
<path
d="m7 10 5 5 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</PopoverPrimitive.Trigger>
);
}
export interface ComboboxContentProps extends ComponentPropsWithRef<
typeof PopoverPrimitive.Content
> {
/** Names the popover, which carries role="dialog". */
label?: string;
}
export function ComboboxContent({
className,
align = "start",
sideOffset = 6,
label = "Search options",
children,
...props
}: ComboboxContentProps) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="combobox-content"
align={align}
sideOffset={sideOffset}
aria-label={label}
// Focus belongs in the search input, which the input claims on mount.
onOpenAutoFocus={(event) => {
event.preventDefault();
}}
className={cn(
"z-[var(--z-popover)] w-[var(--radix-popover-trigger-width)] min-w-48 overflow-hidden",
"rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-lg",
"origin-[var(--radix-popover-content-transform-origin)]",
"data-[state=closed]:animate-float-out data-[state=open]:animate-float-in",
className,
)}
{...props}
>
{children}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
);
}
/** Options currently rendered, in DOM order and excluding disabled ones. */
function visibleOptions(list: HTMLElement | null): HTMLElement[] {
if (!list) return [];
return Array.from(list.querySelectorAll<HTMLElement>('[role="option"]:not([data-disabled])'));
}
export interface ComboboxInputProps extends Omit<
ComponentPropsWithRef<"input">,
"value" | "onChange"
> {
/** Shows a button that clears the search while there is text to clear. */
clearable?: boolean;
}
export function ComboboxInput({ className, clearable = false, ...props }: ComboboxInputProps) {
const {
search,
setSearch,
activeValue,
setActiveValue,
select,
setOpen,
listId,
inputId,
optionId,
listRef,
} = useCombobox("ComboboxInput");
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
const move = useCallback(
(delta: number | "first" | "last") => {
const options = visibleOptions(listRef.current);
if (options.length === 0) return;
const currentIndex = options.findIndex((option) => option.dataset.value === activeValue);
let nextIndex: number;
if (delta === "first") nextIndex = 0;
else if (delta === "last") nextIndex = options.length - 1;
else if (currentIndex === -1) nextIndex = delta > 0 ? 0 : options.length - 1;
// Wraps, so holding an arrow key never dead-ends at the edge of the list.
else nextIndex = (currentIndex + delta + options.length) % options.length;
const next = options[nextIndex];
if (!next) return;
setActiveValue(next.dataset.value);
next.scrollIntoView({ block: "nearest" });
},
[activeValue, listRef, setActiveValue],
);
function handleKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
move(1);
break;
case "ArrowUp":
event.preventDefault();
move(-1);
break;
case "Home":
event.preventDefault();
move("first");
break;
case "End":
event.preventDefault();
move("last");
break;
case "Enter": {
if (activeValue === undefined) return;
event.preventDefault();
select(activeValue);
break;
}
case "Escape":
event.preventDefault();
setOpen(false);
break;
default:
break;
}
}
return (
<div className="flex items-center gap-2 border-b border-border px-3">
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="size-4 shrink-0 opacity-55"
>
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2" />
<path d="m20 20-3.5-3.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<input
ref={inputRef}
id={inputId}
data-slot="combobox-input"
type="text"
role="combobox"
autoComplete="off"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={activeValue ? optionId(activeValue) : undefined}
value={search}
onChange={(event) => {
setSearch(event.target.value);
// The previous active option may have just been filtered out.
setActiveValue(undefined);
}}
onKeyDown={handleKeyDown}
className={cn(
"h-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground",
className,
)}
{...props}
/>
{clearable && search ? (
<button
type="button"
data-slot="combobox-clear"
aria-label="Clear search"
// Keeps focus, and so aria-activedescendant, in the input.
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={() => {
setSearch("");
setActiveValue(undefined);
inputRef.current?.focus();
}}
className={cn(
"-me-1 grid size-6 shrink-0 place-items-center rounded-sm opacity-60 hover:opacity-100",
"transition-opacity duration-[var(--duration-fast)]",
focusRing,
)}
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="size-3.5">
<path
d="M6 6l12 12M18 6 6 18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</button>
) : null}
</div>
);
}
export function ComboboxList({ className, ...props }: ComponentPropsWithRef<"div">) {
const { listId, listRef, loading } = useCombobox("ComboboxList");
return (
<div
ref={listRef}
id={listId}
role="listbox"
aria-busy={loading || undefined}
data-slot="combobox-list"
className={cn("max-h-64 overflow-y-auto overscroll-contain p-1", className)}
{...props}
/>
);
}
export interface ComboboxItemProps extends Omit<
ComponentPropsWithRef<"div">,
"onSelect" | "children"
> {
/** The value reported by onValueChange, and the primary search term. */
value: string;
/** Extra search terms that are not shown, such as synonyms or an id. */
keywords?: string[];
disabled?: boolean;
children?: ReactNode;
}
export function ComboboxItem({
className,
value,
keywords,
disabled,
children,
...props
}: ComboboxItemProps) {
const {
search,
filter,
value: selectedValue,
activeValue,
setActiveValue,
select,
optionId,
registerVisible,
} = useCombobox("ComboboxItem");
// A string child is the visible label, so it is searchable for free. Richer
// children cannot be read purely, so those pass `keywords` instead.
const haystack = useMemo(
() => [value, ...(keywords ?? []), typeof children === "string" ? children : ""],
[value, keywords, children],
);
const visible = filter(search, haystack);
const selected = selectedValue === value;
const active = activeValue === value;
useEffect(() => {
registerVisible(value, visible && !disabled);
return () => {
registerVisible(value, false);
};
}, [registerVisible, value, visible, disabled]);
if (!visible) return null;
return (
<div
id={optionId(value)}
role="option"
data-slot="combobox-item"
data-value={value}
data-active={active || undefined}
data-disabled={disabled || undefined}
aria-selected={selected}
aria-disabled={disabled || undefined}
// Programmatically focusable but out of the tab order: in an
// aria-activedescendant listbox the input keeps real focus and options are
// only virtually focused, so they must never become tab stops.
tabIndex={-1}
onPointerMove={() => {
if (!disabled) setActiveValue(value);
}}
onMouseDown={(event) => {
// Without this, pressing on an option blurs the search input, which
// breaks aria-activedescendant and can dismiss the popover before the
// click ever lands.
event.preventDefault();
}}
onClick={() => {
if (!disabled) select(value);
}}
onKeyDown={(event) => {
// Reachable only if a consumer focuses an option directly; the normal
// path is Enter on the input, handled by ComboboxInput.
if (disabled) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
select(value);
}
}}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md py-1.5 ps-2 pe-8 text-sm outline-none select-none",
"transition-colors duration-[var(--duration-instant)]",
"data-[active]:bg-accent data-[active]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-55",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<style href="dowel-combobox" precedence="dowel">
{ITEM_KEYFRAMES}
</style>
{children ?? value}
{selected ? (
<span className="absolute end-2 grid size-4 place-items-center">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="size-3.5">
<path
d="m5 13 4 4L19 7"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
) : null}
</div>
);
}
/** Shown only when the filter leaves nothing. */
export function ComboboxEmpty({ className, children, ...props }: ComponentPropsWithRef<"div">) {
const { visibleCount, loading } = useCombobox("ComboboxEmpty");
if (visibleCount > 0 || loading) return null;
return (
<div
data-slot="combobox-empty"
role="presentation"
className={cn("px-3 py-6 text-center text-sm text-muted-foreground", className)}
{...props}
>
{children ?? "No results found."}
</div>
);
}
/**
* Shown only while `loading` is set on Combobox. Render it beside ComboboxList,
* as with ComboboxEmpty; pass a Spinner as a child if wanted.
*/
export function ComboboxLoading({
className,
children,
...props
}: ComponentPropsWithRef<"div">) {
const { loading } = useCombobox("ComboboxLoading");
if (!loading) return null;
return (
<div
data-slot="combobox-loading"
role="status"
className={cn(
"flex items-center justify-center gap-2 px-3 py-6 text-sm text-muted-foreground",
"animate-float-in",
className,
)}
{...props}
>
{children ?? "Loading…"}
</div>
);
}