Inputs & Forms

Combobox

A cmdk-powered autocomplete in a Radix popover with origin-aware open, instant filtering, and a highlight that slides between rows.

Install

npx shadcn@latest add @paragon/combobox

combobox.tsx

"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>
  );
}