Combobox
pnpm dlx shadcn@latest add …/r/combobox.jsonAn accessible React combobox for Next.js and Tailwind CSS: type in the field to filter and pick one option, or several as chips, with a gliding highlight.
import { Combobox } from "@/components/ui/combobox"
const frameworks = [
{ value: "next", label: "Next.js" },
{ value: "astro", label: "Astro" },
{ value: "nuxt", label: "Nuxt", keywords: ["vue"] },
]
export function FrameworkPicker() {
return (
<Combobox
aria-label="Framework"
options={frameworks}
placeholder="Pick a framework"
emptyText="No framework found."
/>
)
}"use client";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useId, useState } from "react";
type Size = "sm" | "md";
const frameworks: ComboboxOption[] = [
{ value: "next", label: "Next.js", keywords: ["react", "vercel"] },
{ value: "react-router", label: "React Router", keywords: ["remix", "react"] },
{ value: "astro", label: "Astro" },
{ value: "nuxt", label: "Nuxt", keywords: ["vue"] },
{ value: "sveltekit", label: "SvelteKit", keywords: ["svelte"] },
{ value: "solid-start", label: "SolidStart", keywords: ["solid"] },
{ value: "tanstack-start", label: "TanStack Start", keywords: ["react", "tanstack"] },
];
const labels: ComboboxOption[] = [
{ value: "bug", label: "Bug" },
{ value: "feature", label: "Feature" },
{ value: "docs", label: "Docs", keywords: ["documentation"] },
{ value: "design", label: "Design" },
{ value: "performance", label: "Performance", keywords: ["speed", "perf"] },
{ value: "accessibility", label: "Accessibility", keywords: ["a11y"] },
{ value: "good-first-issue", label: "Good first issue" },
{ value: "help-wanted", label: "Help wanted" },
];
/*
* One value and several side by side, each named underneath, with the size in the top-right corner. An empty
* row at the bottom, as tall as the setting's row, keeps them centred.
*/
export default function ComboboxPickers() {
const id = useId();
const [size, setSize] = useState<Size>("md");
return (
<div className="flex w-full flex-col gap-6 self-stretch">
<div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
<label htmlFor={`${id}-size`} className="text-sm text-muted-foreground">
Size
</label>
<Select value={size} onValueChange={(value) => setSize(value as Size)}>
<SelectTrigger id={`${id}-size`} size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm">Small</SelectItem>
<SelectItem value="md">Medium</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid flex-1 content-center justify-center gap-x-10 gap-y-6 sm:grid-cols-2">
<div className="grid w-64 max-w-full content-start gap-2">
<label htmlFor={`${id}-framework`} className="text-sm font-medium">
Framework
</label>
<Combobox
id={`${id}-framework`}
size={size}
options={frameworks}
defaultValue="next"
placeholder="Pick a framework"
emptyText="No framework found."
/>
<span className="text-xs text-muted-foreground">One value</span>
</div>
<div className="grid w-64 max-w-full content-start gap-2">
<label htmlFor={`${id}-labels`} className="text-sm font-medium">
Labels
</label>
<Combobox
id={`${id}-labels`}
multiple
size={size}
options={labels}
defaultValue={["bug", "design"]}
placeholder="Add labels"
emptyText="No label found."
/>
<span className="text-xs text-muted-foreground">Several, as chips</span>
</div>
</div>
<div aria-hidden className="-mb-6 h-(--size-control-sm)" />
</div>
);
}Installation
Install the dependencies.
$ pnpm add lucide-react motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Add Command first.
Copy the source into your project.
"use client"; import { CommandEmpty, type CommandFilter, CommandGroup, CommandItem, CommandList, CommandProvider, useCommandInput, useGlidingHeight, } from "@/components/ui/command"; import { blur, duration, ease, spring } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { ChevronDownIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Popover as PopoverPrimitive } from "radix-ui"; import { type ComponentProps, Fragment, type KeyboardEvent, type ReactNode, useMemo, useRef, useState, } from "react"; /* * Combobox: type to filter and pick, right in the field (roadmap, 2026-10-16). The field is the text input, * styled like Select's trigger; the options open in a list below it. The list is Command (filtering and * ranking, groups, the gliding highlight and height, the keyboard) inside a Radix Popover (placement, flipping, * Escape and clicks outside). * - One value, or several (`multiple`), shown as chips in the field. * - The keyboard stays in the field (WAI-ARIA's editable combobox with list autocomplete): typing or ArrowDown * opens the list, the arrows move, Enter picks, Escape closes, and Backspace in an empty field removes the * last chip. Home and End stay with the text. * - Motion (.agents/rules/motion.md): * - the chosen option's check draws itself in and erases the way it came (Checkbox's stroke) * - chips come and go through the blur while the others glide into place, and the field's height glides * when they wrap onto another line (the height pattern, never a spring) * - the list opens out of the field and closes back into it through the blur (popover-in/out) * Nothing animates on first paint. With reduced motion everything jumps or only fades. * - One value: the field shows the chosen label. Typing searches from it; leaving without picking puts the * label back, and clearing the text clears the choice. */ export type ComboboxOption = { value: string; /** What the field and the list show, and what the search matches */ label: string; /** More words the search matches */ keywords?: string[]; disabled?: boolean; /** Options with the same group are listed together under it as a heading */ group?: string; }; type ComboboxBaseProps = Omit< ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "size" | "type" | "multiple" | "children" > & { options: ComboboxOption[]; /** Shown when nothing matches */ emptyText?: ReactNode; size?: "sm" | "md"; /** Custom content for an option in the list (the field still shows its label) */ renderOption?: (option: ComboboxOption) => ReactNode; /** Scores an option's label against the search; 0 hides it */ filter?: CommandFilter; /** Called with the search as it changes */ onSearchChange?: (search: string) => void; }; type ComboboxSingleProps = { multiple?: false; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; }; type ComboboxMultipleProps = { multiple: true; value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[]) => void; }; export type ComboboxProps = ComboboxBaseProps & (ComboboxSingleProps | ComboboxMultipleProps); const sizes = { // Rows are the input's height; chips sit centred in them, so wrapped lines keep the same rhythm sm: { field: "min-h-(--size-control-sm) rounded-md", row: "h-7", chip: "my-0.5" }, md: { field: "min-h-(--size-control-md)", row: "h-8", chip: "my-1" }, } as const; const asList = (value?: string | string[]) => value === undefined ? undefined : Array.isArray(value) ? value : value ? [value] : []; /** The same check as Checkbox: it draws itself in, and erases the way it came */ const CHECK = "M5 12.5l4.5 4.5L19 7.5"; function Check({ shown, reduce }: { shown: boolean; reduce: boolean }) { return ( <svg viewBox="0 0 24 24" fill="none" aria-hidden className="size-(--size-icon-sm) text-current"> <motion.path d={CHECK} stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" initial={false} animate={{ pathLength: shown ? 1 : 0, opacity: shown ? 1 : 0 }} transition={ reduce ? { duration: 0 } : shown ? { pathLength: { duration: duration.base, ease: ease.out }, opacity: { duration: 0 } } : { duration: duration.fast, ease: ease.in } } /> </svg> ); } /** The text input, wired to the list by Command (it has to sit inside CommandProvider) */ function FieldInput({ open, onKeyDown, className, ...props }: ComponentProps<"input"> & { open: boolean }) { const field = useCommandInput({ onKeyDown, homeEnd: false }); return ( <input data-slot="combobox-input" type="text" autoComplete="off" autoCorrect="off" spellCheck={false} {...field} role="combobox" aria-expanded={open} className={cn( "w-full min-w-0 bg-transparent text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed", className, )} {...props} /> ); } /** * A text field that filters a list of options as you type, for one value or several (`multiple`, shown as * chips). Give it a visible label (`<label htmlFor>` with its `id`) or an aria-label. */ function Combobox({ multiple = false, value, defaultValue, onValueChange, options, placeholder, emptyText = "No results found.", size = "md", renderOption, filter, onSearchChange, className, name, disabled = false, onKeyDown, onBlur, ref, "aria-label": ariaLabel, "aria-invalid": ariaInvalid, ...props }: ComboboxProps) { const [own, setOwn] = useState<string[]>(() => asList(defaultValue) ?? []); const values = asList(value) ?? own; const [open, setOpen] = useState(false); // The text typed in the field. With one value it only counts while editing; otherwise the label shows. const [text, setText] = useState(""); const [editing, setEditing] = useState(false); const [active, setActive] = useState(""); const field = useRef<HTMLDivElement>(null); const inner = useRef<HTMLDivElement>(null); const input = useRef<HTMLInputElement | null>(null); const content = useRef<HTMLDivElement>(null); const reduce = useReducedMotion() ?? false; const look = sizes[size]; useGlidingHeight(field, inner); const labels = useMemo(() => new Map(options.map((option) => [option.value, option.label])), [options]); const labelOf = (option: string) => labels.get(option) ?? option; // Options in groups of the same `group`, in the order each group first appears const sections = useMemo(() => { const out: { heading?: string; options: ComboboxOption[] }[] = []; for (const option of options) { const section = out.find((entry) => entry.heading === option.group); if (section) section.options.push(option); else out.push({ heading: option.group, options: [option] }); } return out; }, [options]); const shownText = multiple || editing ? text : values[0] ? labelOf(values[0]) : ""; const search = multiple || editing ? text : ""; const invalid = ariaInvalid === true || ariaInvalid === "true"; function commit(next: string[]) { setOwn(next); (onValueChange as ((value: string | string[]) => void) | undefined)?.(multiple ? next : (next[0] ?? "")); } function show() { if (disabled || open) return; // One value opens on the chosen option (Command centres it); several start at the top setActive(multiple ? "" : (values[0] ?? "")); setOpen(true); } function close() { // Clearing the text and leaving clears a single choice; otherwise its label comes back if (!multiple && editing && !text.trim() && values.length) commit([]); setEditing(false); setText(""); setOpen(false); } function pick(option: string) { if (multiple) { // The list stays open, so the check can be seen drawing in or erasing commit(values.includes(option) ? values.filter((entry) => entry !== option) : [...values, option]); return; } commit([option]); setEditing(false); setText(""); setOpen(false); } function onFieldKeyDown(event: KeyboardEvent<HTMLInputElement>) { onKeyDown?.(event); if (event.defaultPrevented) return; if ((event.key === "ArrowDown" || event.key === "ArrowUp") && !open) { event.preventDefault(); show(); } else if (event.key === "Backspace" && multiple && !text && values.length) { commit(values.slice(0, -1)); } // Escape closes through the popover; Enter, the arrows and the rest go on to Command } const item = (option: ComboboxOption) => { const selected = values.includes(option.value); return ( <CommandItem key={option.value} value={option.value} textValue={option.label} keywords={option.keywords} disabled={option.disabled} onSelect={pick} data-checked={selected} // Several: aria-selected says which are chosen. One: it follows the highlight, and the field shows the choice. {...(multiple ? { "aria-selected": selected } : {})} className="ps-2 pe-8" > {renderOption ? renderOption(option) : option.label} <span className="absolute end-2 flex size-(--size-icon-md) items-center justify-center"> <Check shown={selected} reduce={reduce} /> </span> </CommandItem> ); }; const hidden = reduce ? blur.shown : blur.hidden; return ( <CommandProvider search={search} value={active} onValueChange={setActive} filter={filter}> <PopoverPrimitive.Root open={open} onOpenChange={(next) => (next ? show() : close())}> <PopoverPrimitive.Anchor asChild> {/* The input has the keyboard; a press anywhere else in the field focuses it and opens the list, and the chevron closes an open list */} {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer convenience only; the input inside handles the keyboard */} <div ref={field} data-slot="combobox" data-state={open ? "open" : "closed"} data-size={size} data-invalid={invalid || undefined} data-disabled={disabled || undefined} onMouseDown={(event) => { const target = event.target as HTMLElement; const node = input.current; if (disabled || !node || target.closest("[data-slot=combobox-chip-remove]")) return; // With one value, the first press selects its label, so typing replaces it const selectLabel = !multiple && !editing && values.length > 0 && document.activeElement !== node; if (target !== node || selectLabel) { event.preventDefault(); node.focus(); if (selectLabel) node.select(); } if (open && target.closest("[data-slot=combobox-chevron]")) close(); else show(); }} className={cn( "group relative w-full min-w-0 cursor-text rounded-lg border border-input bg-transparent text-sm shadow-xs", "transition duration-(--duration-fast) ease-out", "outline-ring has-focus-visible:border-ring has-focus-visible:outline-(length:--focus-ring-width) has-focus-visible:outline-offset-(--focus-ring-offset)", "data-invalid:border-destructive data-invalid:outline-destructive", "data-disabled:cursor-not-allowed data-disabled:opacity-(--opacity-disabled)", look.field, className, )} > <div ref={inner} className="relative flex flex-wrap items-center gap-x-1 py-px ps-1 pe-8"> {multiple ? ( // A chip that leaves pops out of the flow and fades through the blur where it was, while // the rest glide into place <AnimatePresence mode="popLayout" initial={false}> {values.map((entry) => ( <motion.span key={entry} layout={reduce ? false : "position"} data-slot="combobox-chip" initial={{ opacity: 0, filter: hidden, scale: reduce ? 1 : 0.96 }} animate={{ opacity: 1, filter: blur.shown, scale: 1, transitionEnd: { filter: "none" }, }} exit={{ opacity: 0, filter: hidden, scale: reduce ? 1 : 0.96, transition: { duration: duration.fast, ease: ease.in }, }} transition={{ duration: duration.base, ease: ease.out, layout: spring.snappy }} className={cn( "inline-flex h-6 max-w-full min-w-0 items-center gap-1 rounded-md bg-secondary ps-2 pe-1 text-xs font-medium text-secondary-foreground", look.chip, )} > <span className="truncate">{labelOf(entry)}</span> {/* Out of the tab order: Backspace removes the last chip, and the list unpicks any */} <button type="button" tabIndex={-1} data-slot="combobox-chip-remove" aria-label={`Remove ${labelOf(entry)}`} disabled={disabled} onMouseDown={(event) => event.preventDefault()} onClick={() => commit(values.filter((other) => other !== entry))} className="inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors duration-(--duration-fast) ease-out hover:text-foreground" > <XIcon aria-hidden className="size-3" /> </button> </motion.span> ))} </AnimatePresence> ) : null} <motion.span layout={multiple && !reduce ? "position" : false} transition={spring.snappy} className="flex min-w-16 flex-1" > <FieldInput {...props} ref={(node) => { input.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }} open={open} disabled={disabled} aria-label={ariaLabel} aria-invalid={ariaInvalid} placeholder={multiple && values.length ? undefined : placeholder} value={shownText} onChange={(event) => { setText(event.target.value); if (!multiple) setEditing(true); onSearchChange?.(event.target.value); show(); }} onKeyDown={onFieldKeyDown} onBlur={(event) => { onBlur?.(event); const next = event.relatedTarget; if ( next instanceof Node && (field.current?.contains(next) || content.current?.contains(next)) ) return; close(); }} className={cn(look.row, multiple && values.length ? "ps-1" : "ps-2")} /> </motion.span> </div> <span data-slot="combobox-chevron" aria-hidden className={cn( "absolute end-3 top-px flex cursor-pointer items-center text-muted-foreground", look.row, )} > <ChevronDownIcon className="size-(--size-icon-md) shrink-0 transition-transform duration-(--duration-base) ease-out group-data-[state=open]:rotate-180" /> </span> </div> </PopoverPrimitive.Anchor> <PopoverPrimitive.Portal> {/* Not a dialog: it only holds the listbox, and focus stays in the field */} {/* biome-ignore lint/a11y/useValidAriaRole: removes Radix's role="dialog" rather than setting one */} <PopoverPrimitive.Content ref={content} data-slot="combobox-content" role={undefined} align="start" sideOffset={4} onOpenAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()} onInteractOutside={(event) => { if (event.target instanceof Node && field.current?.contains(event.target)) event.preventDefault(); }} className={cn( "z-(--z-popover) flex max-h-(--radix-popover-content-available-height) w-(--radix-popover-trigger-width) flex-col overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md outline-hidden", "origin-(--radix-popover-content-transform-origin) data-[state=open]:animate-popover-in data-[state=closed]:animate-popover-out", )} > <CommandList label={ariaLabel ?? "Options"} aria-multiselectable={multiple || undefined}> <CommandEmpty>{emptyText}</CommandEmpty> {sections.map((section) => section.heading ? ( <CommandGroup key={section.heading} heading={section.heading}> {section.options.map(item)} </CommandGroup> ) : ( <Fragment key="">{section.options.map(item)}</Fragment> ), )} </CommandList> </PopoverPrimitive.Content> </PopoverPrimitive.Portal> </PopoverPrimitive.Root> {name ? (multiple ? values : [values[0] ?? ""]).map((entry) => ( <input key={entry} type="hidden" name={name} value={entry} disabled={disabled} /> )) : null} </CommandProvider> ); } export { Combobox };Update the import paths to match your project.
Examples
Groups and long lists
Options with a group are listed under it as a heading, and keywords widen the search: try IST or Pacific. renderOption adds detail to each row while the field shows the label. Long lists scroll, with soft fades at the ends.
"use client";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useId } from "react";
/** City, region, country, and the abbreviations and other names people search for */
const zones: [string, string, string, string, string[]][] = [
["America/Los_Angeles", "Los Angeles", "Americas", "United States", ["PST", "Pacific", "San Francisco"]],
["America/Denver", "Denver", "Americas", "United States", ["MST", "Mountain"]],
["America/Chicago", "Chicago", "Americas", "United States", ["CST", "Central"]],
["America/New_York", "New York", "Americas", "United States", ["EST", "Eastern"]],
["America/Toronto", "Toronto", "Americas", "Canada", ["EST", "Eastern"]],
["America/Sao_Paulo", "São Paulo", "Americas", "Brazil", ["BRT", "Sao Paulo"]],
["Europe/London", "London", "Europe and Africa", "United Kingdom", ["GMT", "BST", "UK"]],
["Europe/Paris", "Paris", "Europe and Africa", "France", ["CET"]],
["Europe/Berlin", "Berlin", "Europe and Africa", "Germany", ["CET"]],
["Africa/Lagos", "Lagos", "Europe and Africa", "Nigeria", ["WAT"]],
["Africa/Nairobi", "Nairobi", "Europe and Africa", "Kenya", ["EAT"]],
["Asia/Dubai", "Dubai", "Asia and Pacific", "United Arab Emirates", ["GST", "UAE"]],
["Asia/Kolkata", "Kolkata", "Asia and Pacific", "India", ["IST", "Mumbai", "Delhi", "Bengaluru"]],
["Asia/Singapore", "Singapore", "Asia and Pacific", "Singapore", ["SGT"]],
["Asia/Tokyo", "Tokyo", "Asia and Pacific", "Japan", ["JST"]],
["Australia/Sydney", "Sydney", "Asia and Pacific", "Australia", ["AEST", "AEDT"]],
["Pacific/Auckland", "Auckland", "Asia and Pacific", "New Zealand", ["NZST", "NZDT"]],
];
const options: ComboboxOption[] = zones.map(([value, label, group, country, keywords]) => ({
value,
label,
group,
keywords: [country, ...keywords],
}));
const countries = new Map(zones.map(([value, , , country]) => [value, country]));
/*
* Options grouped under headings, with keywords for the abbreviations people type (IST, Pacific). Each row
* also shows the country; the field still shows the city. The list scrolls, with soft fades at the ends.
* A client file, because renderOption is a function.
*/
export default function ComboboxGroups() {
const id = useId();
return (
<div className="grid w-64 max-w-full gap-2">
<label htmlFor={id} className="text-sm font-medium">
Time zone
</label>
<Combobox
id={id}
options={options}
defaultValue="Asia/Kolkata"
placeholder="Pick a time zone"
emptyText="No time zone found."
renderOption={(option) => (
<span className="flex min-w-0 flex-1 items-baseline justify-between gap-3">
<span className="truncate">{option.label}</span>
<span className="truncate text-xs text-muted-foreground">{countries.get(option.value)}</span>
</span>
)}
/>
</div>
);
}API reference
A text field with a list of options below it, built on Command and Radix Popover. Give it a visible label (<label htmlFor> with its id) or an aria-label. Also accepts every input prop, passed to the field.
| Prop | Type | Default | Description |
|---|---|---|---|
options | { value: string; label: string; keywords?: string[]; disabled?: boolean; group?: string }[] | – | The options. The search matches the label and keywords; options with the same group are listed under it. |
multiple? | boolean | false | Pick several, shown as chips. The value is then a string[]. |
value? | string | string[] | – | The chosen value (or values, with multiple), when you control it. An empty string is none. |
defaultValue? | string | string[] | – | The value chosen at first. |
onValueChange? | (value: string | string[]) => void | – | Called when the choice changes: a string, or a string[] with multiple. |
placeholder? | string | – | The hint in the empty field. |
emptyText? | ReactNode | "No results found." | Shown when nothing matches. |
renderOption? | (option) => ReactNode | – | Custom content for a row in the list. The field still shows the label. |
size? | "sm" | "md" | "md" | The height of one line: Select's small and medium. |
name? | string | – | Submits the value with a form (one hidden input per value with multiple). |
filter? | (text: string, search: string, keywords?: string[]) => number | – | Scores an option against the search; 0 hides it. Defaults to Command's ranking. |
onSearchChange? | (search: string) => void | – | Called with the search as it changes. |
disabled? | boolean | false | Prevents typing and opening. |
aria-invalid? | boolean | – | Marks the value as wrong: a destructive border and focus ring. |
Related components
An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.
An accessible React command menu for Next.js and Tailwind CSS: a ⌘K palette that filters and ranks as you type, with groups, shortcuts and a gliding highlight.
An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.