# Combobox

> Searchable select with keyboard navigation and deselect support.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/combobox
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/combobox.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/combobox-demo.tsx"
import { Combobox } from "@/components/combobox"

const frameworks = [
  { value: "next", label: "Next.js", hint: "React" },
  { value: "remix", label: "Remix", hint: "React" },
  { value: "astro", label: "Astro", hint: "Islands" },
  { value: "nuxt", label: "Nuxt", hint: "Vue" },
  { value: "sveltekit", label: "SvelteKit", hint: "Svelte" },
  { value: "solidstart", label: "SolidStart", hint: "Solid" },
  { value: "qwik", label: "Qwik City", hint: "Qwik" },
]

export default function ComboboxDemo() {
  return (
    <div className="flex h-96 flex-col gap-2 pt-10">
      <span className="text-sm font-medium">Framework</span>
      <Combobox options={frameworks} defaultValue="next" placeholder="Select a framework…" defaultOpen />
    </div>
  )
}
```

## Source

```tsx title="components/combobox.tsx"
"use client"

import * as React from "react"
import { Check, ChevronsUpDown, Search } from "lucide-react"

import { cn } from "@/lib/utils"

export type ComboboxOption = {
  value: string
  label: string
  /** Secondary text on the right of the option */
  hint?: string
}

export interface ComboboxProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  options: ComboboxOption[]
  value?: string | null
  defaultValue?: string | null
  onChange?: (value: string | null) => void
  placeholder?: string
  searchPlaceholder?: string
  emptyText?: string
  /** Show a search field above the options */
  searchable?: boolean
  /** Choosing the selected option again clears it */
  deselectable?: boolean
  /** Opens on first render without moving focus, handy for previews */
  defaultOpen?: boolean
}

export function Combobox({
  options,
  value,
  defaultValue = null,
  onChange,
  placeholder = "Select an option…",
  searchPlaceholder = "Search…",
  emptyText = "No results found.",
  searchable = true,
  deselectable = true,
  defaultOpen = false,
  className,
  ...props
}: ComboboxProps) {
  const [internal, setInternal] = React.useState(defaultValue)
  const selected = value !== undefined ? value : internal
  const [open, setOpen] = React.useState(defaultOpen)
  const [query, setQuery] = React.useState("")
  const [active, setActive] = React.useState(0)
  const root = React.useRef<HTMLDivElement>(null)
  const trigger = React.useRef<HTMLButtonElement>(null)
  const search = React.useRef<HTMLInputElement>(null)
  const list = React.useRef<HTMLUListElement>(null)
  const id = React.useId()

  const filtered = options.filter((option) => option.label.toLowerCase().includes(query.trim().toLowerCase()))
  const current = options.find((option) => option.value === selected)

  React.useEffect(() => {
    if (!open) return
    const onPointerDown = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen(false)
    }
    // Escape from inside is handled below; this covers an open list without focus (defaultOpen)
    const onDocumentKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape" && !root.current?.contains(document.activeElement)) setOpen(false)
    }
    document.addEventListener("pointerdown", onPointerDown)
    document.addEventListener("keydown", onDocumentKeyDown)
    return () => {
      document.removeEventListener("pointerdown", onPointerDown)
      document.removeEventListener("keydown", onDocumentKeyDown)
    }
  }, [open])

  // Scrolls only the list; scrollIntoView would also scroll the page (and an embedding page)
  React.useEffect(() => {
    const el = list.current
    const item = el?.querySelector<HTMLElement>(`[data-index="${active}"]`)
    if (!el || !item) return
    if (item.offsetTop < el.scrollTop) el.scrollTop = item.offsetTop
    else if (item.offsetTop + item.offsetHeight > el.scrollTop + el.clientHeight)
      el.scrollTop = item.offsetTop + item.offsetHeight - el.clientHeight
  }, [active, open])

  const show = () => {
    setOpen(true)
    setQuery("")
    setActive(Math.max(0, options.findIndex((option) => option.value === selected)))
    requestAnimationFrame(() => (searchable ? search.current : list.current)?.focus())
  }

  const close = () => {
    setOpen(false)
    trigger.current?.focus()
  }

  const choose = (option: ComboboxOption) => {
    const next = deselectable && option.value === selected ? null : option.value
    setInternal(next)
    onChange?.(next)
    close()
  }

  const onKeyDown = (event: React.KeyboardEvent) => {
    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
      event.preventDefault()
      const delta = event.key === "ArrowDown" ? 1 : -1
      setActive((i) => (filtered.length ? (i + delta + filtered.length) % filtered.length : 0))
    } else if (event.key === "Enter") {
      event.preventDefault()
      if (filtered[active]) choose(filtered[active])
    } else if (event.key === "Escape") {
      event.preventDefault()
      close()
    } else if (event.key === "Tab") {
      setOpen(false)
    }
  }

  return (
    <div ref={root} className={cn("relative w-64", className)} {...props}>
      <button
        ref={trigger}
        type="button"
        role="combobox"
        aria-expanded={open}
        aria-controls={`${id}-list`}
        aria-haspopup="listbox"
        aria-labelledby={`${id}-value`}
        onClick={() => (open ? setOpen(false) : show())}
        onKeyDown={(event) => {
          if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
            event.preventDefault()
            show()
          }
        }}
        className="flex h-10 w-full items-center justify-between gap-2 rounded-lg border bg-background px-3 text-sm shadow-xs transition-[border-color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20"
      >
        <span id={`${id}-value`} className={cn("truncate", !current && "text-muted-foreground")}>{current?.label ?? placeholder}</span>
        <ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
      </button>
      {open && (
        <div
          onKeyDown={onKeyDown}
          className="absolute inset-x-0 top-full z-20 mt-1.5 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg animate-in fade-in zoom-in-95 slide-in-from-top-1"
        >
          {searchable && (
            <div className="flex items-center gap-2 border-b px-3">
              <Search className="size-4 shrink-0 text-muted-foreground" />
              <input
                ref={search}
                value={query}
                onChange={(event) => {
                  setQuery(event.target.value)
                  setActive(0)
                }}
                placeholder={searchPlaceholder}
                aria-label={searchPlaceholder}
                aria-controls={`${id}-list`}
                aria-activedescendant={filtered[active] ? `${id}-${active}` : undefined}
                className="h-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
              />
            </div>
          )}
          <ul
            ref={list}
            id={`${id}-list`}
            role="listbox"
            tabIndex={-1}
            aria-activedescendant={filtered[active] ? `${id}-${active}` : undefined}
            className="relative max-h-60 overflow-y-auto p-1 outline-none"
          >
            {filtered.length === 0 && <li className="px-2 py-6 text-center text-sm text-muted-foreground">{emptyText}</li>}
            {filtered.map((option, index) => (
              <li
                key={option.value}
                id={`${id}-${index}`}
                data-index={index}
                role="option"
                aria-selected={option.value === selected}
                onPointerMove={() => setActive(index)}
                onClick={() => choose(option)}
                className={cn(
                  "flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm",
                  index === active && "bg-accent text-accent-foreground"
                )}
              >
                <Check className={cn("size-4 shrink-0", option.value === selected ? "opacity-100" : "opacity-0")} />
                <span className="flex-1 truncate">{option.label}</span>
                {option.hint && <span className="text-xs text-muted-foreground">{option.hint}</span>}
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  )
}
```
