A cmdk-powered autocomplete in a Radix popover with origin-aware open, instant filtering, and a highlight that slides between rows.
npx shadcn@latest add @paragon/combobox"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { Command } from "cmdk";
import { motion, useReducedMotion } from "motion/react";
import { Check, ChevronsUpDown, Search } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ComboboxOption {
value: string;
label: string;
disabled?: boolean;
}
export interface ComboboxProps {
options: ComboboxOption[];
/** Controlled selected value. */
value?: string;
/** Initial selected value when uncontrolled. */
defaultValue?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
disabled?: boolean;
/** Applied to the trigger for `htmlFor` wiring. */
id?: string;
/** Form field name; renders a hidden input. */
name?: string;
className?: string;
}
/**
* A cmdk-powered autocomplete inside a Radix popover. The panel opens
* origin-aware from the trigger, filtering is instant, and the highlight
* slides between rows as you arrow through them.
*/
export function Combobox({
options,
value: valueProp,
defaultValue,
onValueChange,
placeholder = "Select an option…",
searchPlaceholder = "Search…",
emptyMessage = "No results found.",
disabled = false,
id,
name,
className,
}: ComboboxProps) {
const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const reduced = useReducedMotion();
const [open, setOpen] = React.useState(false);
const [uncontrolled, setUncontrolled] = React.useState(defaultValue ?? "");
const value = valueProp ?? uncontrolled;
const [highlighted, setHighlighted] = React.useState("");
// Slide the highlight only for arrow/pointer moves — never per keystroke
// while filtering (frequency rule).
const slide = React.useRef(false);
const selected = options.find((opt) => opt.value === value);
const select = (next: string) => {
if (valueProp === undefined) setUncontrolled(next);
onValueChange?.(next);
setOpen(false);
};
return (
<PopoverPrimitive.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) setHighlighted(value);
}}
>
<PopoverPrimitive.Trigger asChild>
<button
type="button"
id={id}
role="combobox"
aria-expanded={open}
aria-controls={`cbx-list-${uid}`}
disabled={disabled}
className={cn(
"flex h-9 w-60 items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-3 text-sm transition-[background-color,border-color] duration-150 ease-out hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50",
className,
)}
>
<span className={cn("truncate", !selected && "text-muted-foreground")}>
{selected ? selected.label : placeholder}
</span>
<ChevronsUpDown
className="size-4 shrink-0 text-muted-foreground"
aria-hidden
/>
</button>
</PopoverPrimitive.Trigger>
{/* Hoisted outside the Portal: React 19 keeps a hoistable <style> as a
child node, and the Radix Portal enforces a single child. */}
<style href={`paragon-combobox-${uid}`} precedence="paragon">{`
@keyframes cbx-in-${uid} { from { opacity: 0; transform: scale(0.97); } }
@keyframes cbx-out-${uid} { to { opacity: 0; transform: scale(0.99); } }
[data-cbx="${uid}"][data-state="open"] { animation: cbx-in-${uid} 150ms var(--ease-out); }
[data-cbx="${uid}"][data-state="closed"] { animation: cbx-out-${uid} 100ms var(--ease-exit) forwards; }
@media (prefers-reduced-motion: reduce) {
@keyframes cbx-in-${uid} { from { opacity: 0; } }
@keyframes cbx-out-${uid} { to { opacity: 0; } }
}
`}</style>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-cbx={uid}
align="start"
sideOffset={6}
className="z-50 w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-overlay"
style={{
transformOrigin: "var(--radix-popover-content-transform-origin)",
}}
>
<Command
loop
value={highlighted}
onValueChange={setHighlighted}
onKeyDownCapture={(e) => {
slide.current = e.key === "ArrowUp" || e.key === "ArrowDown";
}}
>
<div className="flex items-center gap-2 border-b px-3">
<Search
className="size-4 shrink-0 text-muted-foreground"
aria-hidden
/>
<Command.Input
autoFocus
placeholder={searchPlaceholder}
className="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<Command.List
id={`cbx-list-${uid}`}
onPointerMove={() => {
slide.current = true;
}}
className="max-h-64 overflow-y-auto p-1"
>
<Command.Empty>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.15 }}
className="px-2 py-4 text-center text-sm text-muted-foreground"
>
{emptyMessage}
</motion.p>
</Command.Empty>
{options.map((opt) => {
const isHighlighted =
highlighted.toLowerCase() === opt.value.toLowerCase();
return (
<Command.Item
key={opt.value}
value={opt.value}
keywords={[opt.label]}
disabled={opt.disabled}
onSelect={() => select(opt.value)}
className="relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm select-none data-[disabled=true]:opacity-50"
>
{isHighlighted && (
<motion.div
layoutId={`cbx-highlight-${uid}`}
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md bg-accent"
transition={
reduced || !slide.current
? { duration: 0 }
: { type: "spring", duration: 0.25, bounce: 0 }
}
/>
)}
<span className="relative truncate">{opt.label}</span>
{value === opt.value && (
<Check
className="relative ml-auto size-4 shrink-0"
aria-hidden
/>
)}
</Command.Item>
);
})}
</Command.List>
</Command>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
{name && <input type="hidden" name={name} value={value} />}
</PopoverPrimitive.Root>
);
}