# Combobox

Type to filter, with matches highlighted in ember and a highlight that glides between options.

**Category:** Inputs  
**Exports:** `Combobox`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/combobox.json
```
The file lands at `components/ui/combobox.tsx`; import from `@/components/ui/combobox`.

## API — Combobox
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `options` | `{ value: string; label: string; hint?: string }[]` | — |  |
| `value / defaultValue` | `string` | — |  |
| `onValueChange` | `(value: string) => void` | — |  |
| `label` | `string` | — |  |
| `emptyText` | `string` | `"No matches"` |  |

## Example — Combobox

```tsx
import { Combobox } from "@/components/ui/combobox";

const frameworks = [
  { value: "next", label: "Next.js", hint: "React" },
  { value: "remix", label: "Remix", hint: "React" },
  { value: "astro", label: "Astro", hint: "Islands" },
  { value: "vite", label: "Vite", hint: "Bundler" },
  { value: "tanstack", label: "TanStack Start", hint: "React" },
  { value: "nuxt", label: "Nuxt", hint: "Vue" },
  { value: "sveltekit", label: "SvelteKit", hint: "Svelte" },
];

export default function ComboboxDemo({
  label = "Framework",
  placeholder = "Search…",
  emptyText = "No matches",
}: {
  label?: string;
  placeholder?: string;
  emptyText?: string;
}) {
  return (
    <div className="w-full max-w-xs pb-56">
      <Combobox label={label} placeholder={placeholder} emptyText={emptyText} options={frameworks} defaultValue="next" />
    </div>
  );
}
```

## Source — components/ui/combobox.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type ComboboxOption = { value: string; label: string; hint?: string };

export type ComboboxProps = {
  options: ComboboxOption[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  label?: string;
  placeholder?: string;
  emptyText?: string;
  className?: string;
};

function Highlight({ text, query }: { text: string; query: string }) {
  const i = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;
  if (i < 0) return <>{text}</>;
  return (
    <>
      {text.slice(0, i)}
      <mark className="rounded-sm bg-ember/20 text-foreground">{text.slice(i, i + query.length)}</mark>
      {text.slice(i + query.length)}
    </>
  );
}

export function Combobox({ options, value: valueProp, defaultValue, onValueChange, label, placeholder = "Search…", emptyText = "No matches", className }: ComboboxProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const [inner, setInner] = React.useState(defaultValue);
  const value = valueProp ?? inner;
  const selected = options.find((o) => o.value === value);
  const [query, setQuery] = React.useState(selected?.label ?? "");
  const [open, setOpen] = React.useState(false);
  const [active, setActive] = React.useState(0);
  const rootRef = React.useRef<HTMLDivElement>(null);
  const typing = query !== (selected?.label ?? "");
  const results = typing ? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase().trim())) : options;

  React.useEffect(() => setActive(0), [query]);
  React.useEffect(() => {
    if (!open) return;
    const onDown = (e: PointerEvent) => !rootRef.current?.contains(e.target as Node) && close();
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  });

  const close = () => {
    setOpen(false);
    setQuery(selected?.label ?? "");
  };
  const choose = (o: ComboboxOption) => {
    if (valueProp === undefined) setInner(o.value);
    onValueChange?.(o.value);
    setQuery(o.label);
    setOpen(false);
  };

  return (
    <div ref={rootRef} className={cn("relative grid w-full max-w-xs gap-1.5", className)}>
      {label && (
        <label htmlFor={id} className="text-sm font-medium">
          {label}
        </label>
      )}
      <div className="flex h-11 items-center rounded-lg border border-input bg-background transition-[border-color,box-shadow] focus-within:border-foreground/40 focus-within:ring-[3px] focus-within:ring-ring/25">
        <input
          id={id}
          role="combobox"
          aria-expanded={open}
          aria-controls={`${id}-list`}
          aria-autocomplete="list"
          aria-activedescendant={open && results[active] ? `${id}-${results[active].value}` : undefined}
          value={query}
          placeholder={placeholder}
          onChange={(e) => {
            setQuery(e.target.value);
            setOpen(true);
          }}
          onFocus={(e) => {
            e.target.select();
            setOpen(true);
          }}
          onKeyDown={(e) => {
            if (e.key === "ArrowDown") {
              e.preventDefault();
              setOpen(true);
              setActive((a) => Math.min(results.length - 1, a + 1));
            } else if (e.key === "ArrowUp") {
              e.preventDefault();
              setActive((a) => Math.max(0, a - 1));
            } else if (e.key === "Enter" && open && results[active]) {
              e.preventDefault();
              choose(results[active]);
            } else if (e.key === "Escape") close();
            else if (e.key === "Tab") close();
          }}
          className="h-full min-w-0 flex-1 bg-transparent px-3.5 text-base outline-none placeholder:text-muted-foreground sm:text-sm"
        />
        <motion.svg viewBox="0 0 24 24" className="mr-3 size-4 text-muted-foreground" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" animate={{ rotate: open ? 180 : 0 }} aria-hidden>
          <path d="m6 9 6 6 6-6" />
        </motion.svg>
      </div>
      <AnimatePresence>
        {open && (
          <motion.ul
            id={`${id}-list`}
            role="listbox"
            initial={{ opacity: 0, y: reduce ? 0 : -6, scale: reduce ? 1 : 0.98 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: reduce ? 0 : -4, transition: { duration: 0.1 } }}
            transition={{ type: "spring", stiffness: 600, damping: 36 }}
            className="absolute inset-x-0 top-full z-50 mt-2 max-h-60 origin-top overflow-y-auto rounded-xl border bg-popover p-1 shadow-[0_16px_40px_-12px_rgb(0_0_0/0.35)]"
          >
            {results.length === 0 && <li className="px-3 py-6 text-center text-sm text-muted-foreground">{emptyText}</li>}
            {results.map((o, i) => (
              <li
                key={o.value}
                id={`${id}-${o.value}`}
                role="option"
                aria-selected={o.value === value}
                onPointerMove={() => setActive(i)}
                onPointerDown={(e) => e.preventDefault()}
                onClick={() => choose(o)}
                className="relative flex h-9 cursor-pointer items-center gap-2 rounded-lg px-2.5 text-sm"
              >
                {i === active && <motion.span layoutId={`${id}-hl`} className="absolute inset-0 rounded-lg bg-accent" transition={{ type: "spring", stiffness: 700, damping: 45 }} />}
                <span className="relative z-10 flex-1 truncate">
                  <Highlight text={o.label} query={typing ? query.trim() : ""} />
                </span>
                {o.hint && <span className="relative z-10 font-mono text-xs text-muted-foreground">{o.hint}</span>}
                {o.value === value && (
                  <svg viewBox="0 0 24 24" className="relative z-10 size-4" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
                    <path d="M5 12.5l4.5 4.5L19 7" />
                  </svg>
                )}
              </li>
            ))}
          </motion.ul>
        )}
      </AnimatePresence>
    </div>
  );
}
```