FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Combobox

Combobox

pnpm dlx shadcn@latest add …/r/combobox.json

An 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.

One value
BugDesign
Several, as chips
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."
    />
  )
}
pickers.tsx
"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

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add lucide-react motion radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :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;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    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>;
  4. 4

    Add Command first.

  5. 5

    Copy the source into your project.

    components/ui/combobox.tsx
    "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 };
  6. 6

    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.

groups.tsx
"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.

PropTypeDefaultDescription
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?booleanfalsePick 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?booleanfalsePrevents typing and opening.
aria-invalid?boolean–Marks the value as wrong: a destructive border and focus ring.

Related components

  • Select

    An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.

  • Calendar
    Search emoji
    Command

    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.

  • Input

    An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.