Combobox

A searchable single-select built on the ARIA combobox pattern.

Installation

Terminal
pnpm dlx @dowel-ui/cli add combobox

npm packages installed: radix-ui.

Accessibility

The input owns role="combobox" with aria-expanded, aria-controls and aria-autocomplete; the list owns role="listbox" and each option role="option" with aria-selected. Focus stays in the input while arrow keys move aria-activedescendant, so typing is never interrupted. Arrow navigation wraps, Home/End jump, Enter selects the active option and Escape closes. With loading set, the listbox is aria-busy, ComboboxLoading is a status and the empty state waits for the answer. The optional clear button is a named button that returns focus to the input.

Props

Combobox

PropTypeDefault
allowDeselect

Choosing the selected option again clears it, reporting "".

booleanfalse
defaultOpenbooleanfalse
defaultValuestring
filter

Overrides how a search string is matched against an option.

(search: string, haystack: string[]) => booleandefaultComboboxFilter
loading

Results are on their way: the listbox is aria-busy, ComboboxLoading shows and ComboboxEmpty holds back, so "No results" never flashes first.

booleanfalse
onOpenChange(open: boolean) => void
onSearchChange

Called as the search text changes, including the reset when it closes.

(search: string) => void
onValueChange(value: string) => void
openboolean
searchDebounce

Milliseconds of quiet typing before onSearchChange fires.

number0
shouldFilter

Set false when the options are already the results for the search — for example when onSearchChange fetches them — so they are not filtered twice.

booleantrue
valuestring
childrenReactNode

ComboboxTrigger

PropTypeDefault
placeholder

Shown when nothing is selected.

string"Select…"

Plus every attribute of <button>.

ComboboxContent

PropTypeDefault
label

Names the popover, which carries role="dialog".

string"Search options"

Every prop of Radix UI’s Popover.Content, plus className.

ComboboxInput

PropTypeDefault
clearable

Shows a button that clears the search while there is text to clear.

booleanfalse

Plus every attribute of <input> except value, onChange.

ComboboxList

Plus every attribute of <div>.

ComboboxItem

PropTypeDefault
value (required)

The value reported by onValueChange, and the primary search term.

string
disabledboolean
keywords

Extra search terms that are not shown, such as synonyms or an id.

string[]

Plus every attribute of <div> except onSelect, children.

ComboboxEmpty

Plus every attribute of <div>.

ComboboxLoading

Plus every attribute of <div>.

Quality

10/10 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedpasses
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Source

This is exactly what dowel add combobox writes into your project, with imports rewritten to your own path alias.

ui/combobox.tsx
"use client";

// Motion from SmoothUI Combobox and SearchableDropdown (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.

import { Popover as PopoverPrimitive } from "radix-ui";
import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type ComponentPropsWithRef,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from "react";

import { focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

/**
 * A searchable single-select.
 *
 * Implements the ARIA combobox pattern directly on a popover rather than
 * depending on a command-menu package: the input owns `role="combobox"`, the
 * list owns `role="listbox"`, and the active option is tracked with
 * `aria-activedescendant` so focus never leaves the input while typing.
 *
 * Navigation reads the rendered options from the DOM rather than a registry,
 * which keeps the active option correct as filtering adds and removes items —
 * a registry has to be kept in sync with what is actually on screen, and drifts.
 *
 * This version is single-select and flat. Multi-select and grouping can be
 * added without changing this API.
 */

/** Default match: case-insensitive substring over the value and any keywords. */
export function defaultComboboxFilter(search: string, haystack: string[]): boolean {
  const needle = search.trim().toLowerCase();
  if (!needle) return true;
  return haystack.some((entry) => entry.toLowerCase().includes(needle));
}

interface ComboboxContextValue {
  open: boolean;
  setOpen: (open: boolean) => void;
  search: string;
  setSearch: (search: string) => void;
  value: string | undefined;
  select: (value: string) => void;
  activeValue: string | undefined;
  setActiveValue: (value: string | undefined) => void;
  filter: (search: string, haystack: string[]) => boolean;
  listId: string;
  inputId: string;
  optionId: (value: string) => string;
  listRef: React.RefObject<HTMLDivElement | null>;
  registerVisible: (value: string, visible: boolean) => void;
  visibleCount: number;
  loading: boolean;
}

const ComboboxContext = createContext<ComboboxContextValue | null>(null);

function useCombobox(component: string): ComboboxContextValue {
  const context = useContext(ComboboxContext);
  if (!context) {
    throw new Error(`${component} must be rendered inside <Combobox>.`);
  }
  return context;
}

export interface ComboboxProps {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Overrides how a search string is matched against an option. */
  filter?: (search: string, haystack: string[]) => boolean;
  /**
   * Set `false` when the options are already the results for the search — for
   * example when `onSearchChange` fetches them — so they are not filtered twice.
   */
  shouldFilter?: boolean;
  /** Called as the search text changes, including the reset when it closes. */
  onSearchChange?: (search: string) => void;
  /** Milliseconds of quiet typing before `onSearchChange` fires. */
  searchDebounce?: number;
  /**
   * Results are on their way: the listbox is `aria-busy`, `ComboboxLoading`
   * shows and `ComboboxEmpty` holds back, so "No results" never flashes first.
   */
  loading?: boolean;
  /** Choosing the selected option again clears it, reporting `""`. */
  allowDeselect?: boolean;
  children?: ReactNode;
}

/*
 * Motion (SmoothUI's Combobox): options rise in, the first few a beat apart,
 * and the trigger's chevron turns while open. Keyframes ship with the
 * component (ADR 0014); every duration and delay runs on the motion scale.
 */
const ITEM_STAGGER = Array.from(
  { length: 8 },
  (_, index) =>
    `[data-slot="combobox-item"]:nth-child(${String(index + 2)}){animation-delay:calc(${String((index + 1) * 20)}ms * var(--motion-scale, 1))}`,
).join("");

const ITEM_KEYFRAMES = `
@keyframes dowel-combobox-item-in{from{opacity:0;transform:translateY(0.25rem)}}
[data-slot="combobox-item"]{animation:dowel-combobox-item-in var(--duration-normal) var(--ease-out-quint) backwards}
${ITEM_STAGGER}
`;

export function Combobox({
  value: valueProp,
  defaultValue,
  onValueChange,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  filter: filterProp = defaultComboboxFilter,
  shouldFilter = true,
  onSearchChange,
  searchDebounce = 0,
  loading = false,
  allowDeselect = false,
  children,
}: ComboboxProps) {
  const uid = useId();
  const filter = shouldFilter ? filterProp : acceptAll;

  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
  const value = valueProp === undefined ? uncontrolledValue : valueProp;

  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
  const open = openProp === undefined ? uncontrolledOpen : openProp;

  const [search, setSearchState] = useState("");
  const searchRef = useRef("");
  const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const onSearchChangeRef = useRef(onSearchChange);
  useEffect(() => {
    onSearchChangeRef.current = onSearchChange;
  }, [onSearchChange]);

  const setSearch = useCallback(
    (next: string) => {
      if (next === searchRef.current) return;
      searchRef.current = next;
      setSearchState(next);
      clearTimeout(debounceRef.current);
      if (searchDebounce <= 0) {
        onSearchChangeRef.current?.(next);
        return;
      }
      debounceRef.current = setTimeout(() => {
        onSearchChangeRef.current?.(next);
      }, searchDebounce);
    },
    [searchDebounce],
  );

  useEffect(
    () => () => {
      clearTimeout(debounceRef.current);
    },
    [],
  );
  const [activeValue, setActiveValue] = useState<string | undefined>(undefined);
  const listRef = useRef<HTMLDivElement | null>(null);

  // Counted rather than listed: the empty state only needs to know whether
  // anything survived the filter.
  const visibleValues = useRef(new Set<string>());
  const [visibleCount, setVisibleCount] = useState(0);

  const registerVisible = useCallback((optionValue: string, visible: boolean) => {
    const set = visibleValues.current;
    const had = set.has(optionValue);
    if (visible && !had) set.add(optionValue);
    else if (!visible && had) set.delete(optionValue);
    else return;
    setVisibleCount(set.size);
  }, []);

  const setOpen = useCallback(
    (next: boolean) => {
      if (openProp === undefined) setUncontrolledOpen(next);
      onOpenChange?.(next);
      // Reopening should start from a clean search rather than resuming a
      // half-typed query the user has forgotten about.
      if (!next) {
        setSearch("");
        setActiveValue(undefined);
      }
    },
    [openProp, onOpenChange, setSearch],
  );

  const select = useCallback(
    (chosen: string) => {
      const next = allowDeselect && chosen === value ? "" : chosen;
      if (valueProp === undefined) setUncontrolledValue(next || undefined);
      onValueChange?.(next);
      setOpen(false);
    },
    [valueProp, onValueChange, setOpen, allowDeselect, value],
  );

  const context = useMemo<ComboboxContextValue>(
    () => ({
      open,
      setOpen,
      search,
      setSearch,
      value,
      select,
      activeValue,
      setActiveValue,
      filter,
      listId: `${uid}-list`,
      inputId: `${uid}-input`,
      optionId: (optionValue: string) =>
        `${uid}-option-${optionValue.replace(/\s+/g, "-").toLowerCase()}`,
      listRef,
      registerVisible,
      visibleCount,
      loading,
    }),
    [
      open,
      setOpen,
      search,
      setSearch,
      loading,
      value,
      select,
      activeValue,
      filter,
      uid,
      registerVisible,
      visibleCount,
    ],
  );

  return (
    <ComboboxContext.Provider value={context}>
      <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
        {children}
      </PopoverPrimitive.Root>
    </ComboboxContext.Provider>
  );
}

function acceptAll(): boolean {
  return true;
}

export interface ComboboxTriggerProps extends ComponentPropsWithRef<"button"> {
  /** Shown when nothing is selected. */
  placeholder?: string;
}

export function ComboboxTrigger({
  className,
  placeholder = "Select…",
  children,
  ...props
}: ComboboxTriggerProps) {
  const { value, open } = useCombobox("ComboboxTrigger");

  return (
    <PopoverPrimitive.Trigger asChild>
      <button
        type="button"
        data-slot="combobox-trigger"
        aria-expanded={open}
        className={cn(
          "flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-background",
          "px-3 text-sm shadow-xs transition-[border-color,box-shadow] duration-[var(--duration-fast)]",
          "focus-visible:border-ring",
          "disabled:cursor-not-allowed disabled:opacity-55",
          "aria-invalid:border-destructive",
          focusRing,
          className,
        )}
        {...props}
      >
        <span className={cn("truncate", !value && "text-muted-foreground")}>
          {children ?? value ?? placeholder}
        </span>
        <svg
          viewBox="0 0 24 24"
          fill="none"
          aria-hidden="true"
          data-slot="combobox-chevron"
          className={cn(
            "size-4 shrink-0 opacity-60",
            "transition-[rotate] duration-[var(--duration-normal)] ease-[var(--ease-out-quint)]",
            open && "rotate-180",
          )}
        >
          <path
            d="m7 10 5 5 5-5"
            stroke="currentColor"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
      </button>
    </PopoverPrimitive.Trigger>
  );
}

export interface ComboboxContentProps extends ComponentPropsWithRef<
  typeof PopoverPrimitive.Content
> {
  /** Names the popover, which carries role="dialog". */
  label?: string;
}

export function ComboboxContent({
  className,
  align = "start",
  sideOffset = 6,
  label = "Search options",
  children,
  ...props
}: ComboboxContentProps) {
  return (
    <PopoverPrimitive.Portal>
      <PopoverPrimitive.Content
        data-slot="combobox-content"
        align={align}
        sideOffset={sideOffset}
        aria-label={label}
        // Focus belongs in the search input, which the input claims on mount.
        onOpenAutoFocus={(event) => {
          event.preventDefault();
        }}
        className={cn(
          "z-[var(--z-popover)] w-[var(--radix-popover-trigger-width)] min-w-48 overflow-hidden",
          "rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-lg",
          "origin-[var(--radix-popover-content-transform-origin)]",
          "data-[state=closed]:animate-float-out data-[state=open]:animate-float-in",
          className,
        )}
        {...props}
      >
        {children}
      </PopoverPrimitive.Content>
    </PopoverPrimitive.Portal>
  );
}

/** Options currently rendered, in DOM order and excluding disabled ones. */
function visibleOptions(list: HTMLElement | null): HTMLElement[] {
  if (!list) return [];
  return Array.from(list.querySelectorAll<HTMLElement>('[role="option"]:not([data-disabled])'));
}

export interface ComboboxInputProps extends Omit<
  ComponentPropsWithRef<"input">,
  "value" | "onChange"
> {
  /** Shows a button that clears the search while there is text to clear. */
  clearable?: boolean;
}

export function ComboboxInput({ className, clearable = false, ...props }: ComboboxInputProps) {
  const {
    search,
    setSearch,
    activeValue,
    setActiveValue,
    select,
    setOpen,
    listId,
    inputId,
    optionId,
    listRef,
  } = useCombobox("ComboboxInput");

  const inputRef = useRef<HTMLInputElement | null>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  const move = useCallback(
    (delta: number | "first" | "last") => {
      const options = visibleOptions(listRef.current);
      if (options.length === 0) return;

      const currentIndex = options.findIndex((option) => option.dataset.value === activeValue);

      let nextIndex: number;
      if (delta === "first") nextIndex = 0;
      else if (delta === "last") nextIndex = options.length - 1;
      else if (currentIndex === -1) nextIndex = delta > 0 ? 0 : options.length - 1;
      // Wraps, so holding an arrow key never dead-ends at the edge of the list.
      else nextIndex = (currentIndex + delta + options.length) % options.length;

      const next = options[nextIndex];
      if (!next) return;
      setActiveValue(next.dataset.value);
      next.scrollIntoView({ block: "nearest" });
    },
    [activeValue, listRef, setActiveValue],
  );

  function handleKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
    switch (event.key) {
      case "ArrowDown":
        event.preventDefault();
        move(1);
        break;
      case "ArrowUp":
        event.preventDefault();
        move(-1);
        break;
      case "Home":
        event.preventDefault();
        move("first");
        break;
      case "End":
        event.preventDefault();
        move("last");
        break;
      case "Enter": {
        if (activeValue === undefined) return;
        event.preventDefault();
        select(activeValue);
        break;
      }
      case "Escape":
        event.preventDefault();
        setOpen(false);
        break;
      default:
        break;
    }
  }

  return (
    <div className="flex items-center gap-2 border-b border-border px-3">
      <svg
        viewBox="0 0 24 24"
        fill="none"
        aria-hidden="true"
        className="size-4 shrink-0 opacity-55"
      >
        <circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2" />
        <path d="m20 20-3.5-3.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
      </svg>
      <input
        ref={inputRef}
        id={inputId}
        data-slot="combobox-input"
        type="text"
        role="combobox"
        autoComplete="off"
        aria-expanded="true"
        aria-controls={listId}
        aria-autocomplete="list"
        aria-activedescendant={activeValue ? optionId(activeValue) : undefined}
        value={search}
        onChange={(event) => {
          setSearch(event.target.value);
          // The previous active option may have just been filtered out.
          setActiveValue(undefined);
        }}
        onKeyDown={handleKeyDown}
        className={cn(
          "h-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground",
          className,
        )}
        {...props}
      />
      {clearable && search ? (
        <button
          type="button"
          data-slot="combobox-clear"
          aria-label="Clear search"
          // Keeps focus, and so aria-activedescendant, in the input.
          onMouseDown={(event) => {
            event.preventDefault();
          }}
          onClick={() => {
            setSearch("");
            setActiveValue(undefined);
            inputRef.current?.focus();
          }}
          className={cn(
            "-me-1 grid size-6 shrink-0 place-items-center rounded-sm opacity-60 hover:opacity-100",
            "transition-opacity duration-[var(--duration-fast)]",
            focusRing,
          )}
        >
          <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="size-3.5">
            <path
              d="M6 6l12 12M18 6 6 18"
              stroke="currentColor"
              strokeWidth="2"
              strokeLinecap="round"
            />
          </svg>
        </button>
      ) : null}
    </div>
  );
}

export function ComboboxList({ className, ...props }: ComponentPropsWithRef<"div">) {
  const { listId, listRef, loading } = useCombobox("ComboboxList");

  return (
    <div
      ref={listRef}
      id={listId}
      role="listbox"
      aria-busy={loading || undefined}
      data-slot="combobox-list"
      className={cn("max-h-64 overflow-y-auto overscroll-contain p-1", className)}
      {...props}
    />
  );
}

export interface ComboboxItemProps extends Omit<
  ComponentPropsWithRef<"div">,
  "onSelect" | "children"
> {
  /** The value reported by onValueChange, and the primary search term. */
  value: string;
  /** Extra search terms that are not shown, such as synonyms or an id. */
  keywords?: string[];
  disabled?: boolean;
  children?: ReactNode;
}

export function ComboboxItem({
  className,
  value,
  keywords,
  disabled,
  children,
  ...props
}: ComboboxItemProps) {
  const {
    search,
    filter,
    value: selectedValue,
    activeValue,
    setActiveValue,
    select,
    optionId,
    registerVisible,
  } = useCombobox("ComboboxItem");

  // A string child is the visible label, so it is searchable for free. Richer
  // children cannot be read purely, so those pass `keywords` instead.
  const haystack = useMemo(
    () => [value, ...(keywords ?? []), typeof children === "string" ? children : ""],
    [value, keywords, children],
  );

  const visible = filter(search, haystack);
  const selected = selectedValue === value;
  const active = activeValue === value;

  useEffect(() => {
    registerVisible(value, visible && !disabled);
    return () => {
      registerVisible(value, false);
    };
  }, [registerVisible, value, visible, disabled]);

  if (!visible) return null;

  return (
    <div
      id={optionId(value)}
      role="option"
      data-slot="combobox-item"
      data-value={value}
      data-active={active || undefined}
      data-disabled={disabled || undefined}
      aria-selected={selected}
      aria-disabled={disabled || undefined}
      // Programmatically focusable but out of the tab order: in an
      // aria-activedescendant listbox the input keeps real focus and options are
      // only virtually focused, so they must never become tab stops.
      tabIndex={-1}
      onPointerMove={() => {
        if (!disabled) setActiveValue(value);
      }}
      onMouseDown={(event) => {
        // Without this, pressing on an option blurs the search input, which
        // breaks aria-activedescendant and can dismiss the popover before the
        // click ever lands.
        event.preventDefault();
      }}
      onClick={() => {
        if (!disabled) select(value);
      }}
      onKeyDown={(event) => {
        // Reachable only if a consumer focuses an option directly; the normal
        // path is Enter on the input, handled by ComboboxInput.
        if (disabled) return;
        if (event.key === "Enter" || event.key === " ") {
          event.preventDefault();
          select(value);
        }
      }}
      className={cn(
        "relative flex cursor-default items-center gap-2 rounded-md py-1.5 ps-2 pe-8 text-sm outline-none select-none",
        "transition-colors duration-[var(--duration-instant)]",
        "data-[active]:bg-accent data-[active]:text-accent-foreground",
        "data-[disabled]:pointer-events-none data-[disabled]:opacity-55",
        "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className,
      )}
      {...props}
    >
      <style href="dowel-combobox" precedence="dowel">
        {ITEM_KEYFRAMES}
      </style>
      {children ?? value}
      {selected ? (
        <span className="absolute end-2 grid size-4 place-items-center">
          <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="size-3.5">
            <path
              d="m5 13 4 4L19 7"
              stroke="currentColor"
              strokeWidth="2.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        </span>
      ) : null}
    </div>
  );
}

/** Shown only when the filter leaves nothing. */
export function ComboboxEmpty({ className, children, ...props }: ComponentPropsWithRef<"div">) {
  const { visibleCount, loading } = useCombobox("ComboboxEmpty");
  if (visibleCount > 0 || loading) return null;

  return (
    <div
      data-slot="combobox-empty"
      role="presentation"
      className={cn("px-3 py-6 text-center text-sm text-muted-foreground", className)}
      {...props}
    >
      {children ?? "No results found."}
    </div>
  );
}

/**
 * Shown only while `loading` is set on Combobox. Render it beside ComboboxList,
 * as with ComboboxEmpty; pass a Spinner as a child if wanted.
 */
export function ComboboxLoading({
  className,
  children,
  ...props
}: ComponentPropsWithRef<"div">) {
  const { loading } = useCombobox("ComboboxLoading");
  if (!loading) return null;

  return (
    <div
      data-slot="combobox-loading"
      role="status"
      className={cn(
        "flex items-center justify-center gap-2 px-3 py-6 text-sm text-muted-foreground",
        "animate-float-in",
        className,
      )}
      {...props}
    >
      {children ?? "Loading…"}
    </div>
  );
}