Combobox

A text input that filters a known list. Reach for it when a Select has grown past

Forms Combobox.jsx Combobox.d.ts Combobox.md

Playground

Change a prop and watch it. The code below follows along.

Filters a known list. Select is right until the list is too long to scan.

Install and import

Combobox is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Combobox } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

A text input that filters a known list. Reach for it when a Select has grown past the point where someone can scan it.

Use it when

  • The list is long enough that typing beats scrolling, roughly a dozen options and up.
  • The options are known and fixed. A combobox filters; it does not create.

Don't use it when

  • There are a handful of options. Use Select: it is one control, it works without JavaScript, and every platform already knows how to render it.
  • The user may need to enter something not on the list. That is a different control, and it needs to say so.
  • The options come from a server as you type. This one filters what it was given.

Example

<Combobox
  label="Country"
  options={countries}
  value={country}
  onChange={setCountry}
  hint="Where the account is billed"
/>

Keyboard

KeyDoes
Down / UpOpens the list, then moves the active option
EnterSelects the active option and closes
EscapeCloses and puts the selected label back in the box
Home / EndJumps to the first or last match
TabCloses and moves on, leaving the selection as it was

Accessibility

The input is role="combobox" with aria-expanded, aria-controls and aria-autocomplete="list"; the active option is pointed at with aria-activedescendant rather than being focused, so focus never leaves the input. Options carry aria-selected. Give it a label. A combobox with placeholder text and no label is the most common failure in this pattern.

Tokens

Reads the --dt-input-* tier so it lines up with Input and Select to the pixel, and --dt-surface-overlay with --dt-elevation-3 for the list, so it sits in the same overlay family as Popover.

Content

The label names the field, not the action: "Country", not "Choose a country". The empty message says what happened, not sorry: "No matches".

Props

The typed contract, from Combobox.d.ts.

import * as React from "react";

export type ComboboxOption = string | { value: string; label: string };

/** A text input that filters a known list of options. Select is the right control until the list is too long to scan. */
export interface ComboboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "size"> {
  label?: React.ReactNode;
  hint?: React.ReactNode;
  error?: React.ReactNode;
  required?: boolean;
  /** The full list. Filtering happens on the label, case-insensitively. */
  options?: ComboboxOption[];
  /** Controlled selection, by option value. */
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  /** @default "Search…" */
  placeholder?: string;
  /** Shown in the list when nothing matches. @default "No matches" */
  emptyMessage?: React.ReactNode;
  disabled?: boolean;
  /** @default "md" */
  size?: "sm" | "md" | "lg";
  id?: string;
}

export declare function Combobox(props: ComboboxProps): React.JSX.Element;

Tokens it reads

Every custom property Combobox.jsx resolves, read from the source: 16 component, 22 semantic, 1 primitive. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
  • primitive Raw values. A component reading one directly is a deliberate exception.
TokenTierDeclared as
--dt-input-bgcomponentvar(--dt-surface-base)
--dt-input-bg-disabledcomponentvar(--dt-surface-disabled)
--dt-input-bordercomponentvar(--dt-border-default)
--dt-input-border-errorcomponentvar(--dt-surface-danger)
--dt-input-border-focuscomponentvar(--dt-focus-ring-color)
--dt-input-border-widthcomponentvar(--dt-border-width-default)
--dt-input-fgcomponentvar(--dt-text-primary)
--dt-input-fg-disabledcomponentvar(--dt-text-disabled)
--dt-input-font-familycomponentvar(--dt-text-body-md-family)
--dt-input-font-sizecomponentvar(--dt-font-size-sm)
--dt-input-height-lgcomponentvar(--dt-size-control-lg)
--dt-input-height-mdcomponentvar(--dt-size-control-md)
--dt-input-height-smcomponentvar(--dt-size-control-sm)
--dt-input-padding-xcomponentvar(--dt-space-inset-sm)
--dt-input-radiuscomponentvar(--dt-radius-control)
--dt-input-transitioncomponentvar(--dt-motion-micro)
--dt-border-subtlesemanticvar(--dt-color-neutral-100)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-elevation-3semanticvar(--dt-shadow-raw-3)
--dt-radius-containersemanticvar(--dt-radius-raw-16)
--dt-radius-controlsemanticvar(--dt-radius-raw-8)
--dt-size-icon-smsemanticvar(--dt-dim-4)
--dt-space-inline-smsemanticvar(--dt-dim-3)
--dt-space-inset-2xssemanticvar(--dt-dim-1)
--dt-space-inset-smsemanticvar(--dt-dim-3)
--dt-space-inset-xssemanticvar(--dt-dim-2)
--dt-space-stack-2xssemanticvar(--dt-dim-1)
--dt-surface-action-ghost-hoversemanticvar(--dt-color-neutral-100)
--dt-surface-overlaysemanticvar(--dt-color-white)
--dt-surface-selectedsemanticvar(--dt-color-neutral-100)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-on-selectedsemanticvar(--dt-color-neutral-900)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-text-tertiarysemanticvar(--dt-color-neutral-500)
--dt-z-dropdownsemantic200
--dt-dim-10primitive40px

Source

React only, no dependencies, every value a token.

import React from "react";
import { Field } from "./Field.jsx";

const norm = (o) => (typeof o === "string" ? { value: o, label: o } : o);

export function Combobox({
  label,
  hint,
  error,
  required,
  options = [],
  value,
  defaultValue = "",
  onChange,
  placeholder = "Search…",
  emptyMessage = "No matches",
  disabled = false,
  size = "md",
  id,
  style,
  ...rest
}) {
  const all = React.useMemo(() => options.map(norm), [options]);
  const auto = React.useId();
  const inputId = id || auto;
  const listId = inputId + "-list";

  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
  const selected = value !== undefined ? value : uncontrolled;
  const selectedOption = all.find((o) => o.value === selected);

  const [query, setQuery] = React.useState("");
  const [open, setOpen] = React.useState(false);
  const [active, setActive] = React.useState(0);
  const [focus, setFocus] = React.useState(false);
  const wrap = React.useRef(null);
  const list = React.useRef(null);

  const matches = React.useMemo(() => {
    const q = query.trim().toLowerCase();
    return q ? all.filter((o) => o.label.toLowerCase().includes(q)) : all;
  }, [all, query]);

  // The text in the box is what you typed while open, and the chosen label when closed.
  const text = open ? query : selectedOption ? selectedOption.label : "";

  React.useEffect(() => {
    if (!open) return;
    const away = (e) => { if (wrap.current && !wrap.current.contains(e.target)) close(); };
    document.addEventListener("mousedown", away);
    return () => document.removeEventListener("mousedown", away);
  }, [open]);

  // Keep the active option in view when the keyboard moves it past the edge.
  React.useEffect(() => {
    if (!open || !list.current) return;
    const el = list.current.children[active];
    if (el && el.scrollIntoView) el.scrollIntoView({ block: "nearest" });
  }, [active, open]);

  function openList() {
    if (disabled) return;
    setQuery("");
    setActive(Math.max(0, all.findIndex((o) => o.value === selected)));
    setOpen(true);
  }

  function close() {
    setOpen(false);
    setQuery("");
  }

  function choose(option) {
    if (!option) return;
    if (value === undefined) setUncontrolled(option.value);
    if (onChange) onChange(option.value);
    close();
  }

  function onKeyDown(e) {
    if (e.key === "ArrowDown" || e.key === "ArrowUp") {
      e.preventDefault();
      if (!open) return openList();
      const step = e.key === "ArrowDown" ? 1 : -1;
      setActive((i) => (matches.length ? (i + step + matches.length) % matches.length : 0));
      return;
    }
    if (!open) return;
    if (e.key === "Enter") { e.preventDefault(); choose(matches[active]); }
    else if (e.key === "Escape") { e.preventDefault(); close(); }
    else if (e.key === "Home") { e.preventDefault(); setActive(0); }
    else if (e.key === "End") { e.preventDefault(); setActive(Math.max(0, matches.length - 1)); }
    else if (e.key === "Tab") close();
  }

  const border = error
    ? "var(--dt-input-border-error)"
    : focus || open
      ? "var(--dt-input-border-focus)"
      : "var(--dt-input-border)";

  const control = (
    <div ref={wrap} style={{ position: "relative" }}>
      <input
        id={inputId}
        type="text"
        role="combobox"
        autoComplete="off"
        aria-expanded={open}
        aria-controls={listId}
        aria-autocomplete="list"
        aria-activedescendant={open && matches[active] ? listId + "-" + active : undefined}
        aria-invalid={error ? true : undefined}
        required={required}
        disabled={disabled}
        placeholder={placeholder}
        value={text}
        onChange={(e) => { setQuery(e.target.value); setActive(0); if (!open) setOpen(true); }}
        onFocus={() => setFocus(true)}
        onBlur={() => setFocus(false)}
        onMouseDown={() => (open ? close() : openList())}
        onKeyDown={onKeyDown}
        style={{
          width: "100%",
          height: `var(--dt-input-height-${size})`,
          padding: "0 calc(var(--dt-input-padding-x) * 2 + var(--dt-size-icon-sm)) 0 var(--dt-input-padding-x)",
          fontFamily: "var(--dt-input-font-family)",
          fontSize: "var(--dt-input-font-size)",
          color: disabled ? "var(--dt-input-fg-disabled)" : "var(--dt-input-fg)",
          background: disabled ? "var(--dt-input-bg-disabled)" : "var(--dt-input-bg)",
          border: `var(--dt-input-border-width) solid ${border}`,
          borderRadius: "var(--dt-input-radius)",
          outline: "none",
          transition: "border-color var(--dt-input-transition)",
          boxSizing: "border-box",
        }}
        {...rest}
      />
      <svg
        aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
        style={{
          position: "absolute", top: "50%", right: "var(--dt-input-padding-x)", transform: "translateY(-50%)",
          width: "var(--dt-size-icon-sm)", height: "var(--dt-size-icon-sm)",
          pointerEvents: "none", color: "var(--dt-text-secondary)",
        }}
      >
        <path d="m6 9 6 6 6-6" />
      </svg>

      <ul
        id={listId}
        role="listbox"
        aria-label={typeof label === "string" ? label : undefined}
        ref={list}
        hidden={!open}
        style={{
          position: "absolute", top: "calc(100% + var(--dt-space-stack-2xs))", left: 0, right: 0,
          zIndex: "var(--dt-z-dropdown)",
          margin: 0, padding: "var(--dt-space-inset-2xs)", listStyle: "none",
          maxHeight: "calc(var(--dt-dim-10) * 5)", overflowY: "auto",
          background: "var(--dt-surface-overlay)",
          border: "var(--dt-border-width-default) solid var(--dt-border-subtle)",
          borderRadius: "var(--dt-radius-container)",
          boxShadow: "var(--dt-elevation-3)",
        }}
      >
        {matches.length === 0 && (
          <li
            role="option"
            aria-disabled="true"
            aria-selected="false"
            style={{
              padding: "var(--dt-space-inset-xs) var(--dt-space-inset-sm)",
              fontFamily: "var(--dt-text-body-sm-family)", fontSize: "var(--dt-text-body-sm-size)",
              color: "var(--dt-text-tertiary)",
            }}
          >
            {emptyMessage}
          </li>
        )}
        {matches.map((o, i) => {
          const isActive = i === active;
          const isSelected = o.value === selected;
          return (
            <li
              key={o.value}
              id={listId + "-" + i}
              role="option"
              aria-selected={isSelected}
              onMouseEnter={() => setActive(i)}
              onMouseDown={(e) => { e.preventDefault(); choose(o); }}
              style={{
                display: "flex", alignItems: "center", justifyContent: "space-between",
                gap: "var(--dt-space-inline-sm)",
                padding: "var(--dt-space-inset-xs) var(--dt-space-inset-sm)",
                borderRadius: "var(--dt-radius-control)",
                fontFamily: "var(--dt-text-body-sm-family)", fontSize: "var(--dt-text-body-sm-size)",
                lineHeight: "var(--dt-text-body-sm-line)",
                color: isSelected ? "var(--dt-text-on-selected)" : "var(--dt-text-primary)",
                background: isSelected
                  ? "var(--dt-surface-selected)"
                  : isActive
                    ? "var(--dt-surface-action-ghost-hover)"
                    : "transparent",
                cursor: "pointer",
              }}
            >
              <span>{o.label}</span>
              {isSelected && (
                <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
                  style={{ width: "var(--dt-size-icon-sm)", height: "var(--dt-size-icon-sm)", flex: "none" }}>
                  <path d="m5 13 4 4L19 7" />
                </svg>
              )}
            </li>
          );
        })}
      </ul>
    </div>
  );

  if (!label && !hint && !error) return <div style={style}>{control}</div>;
  return (
    <Field label={label} hint={hint} error={error} required={required} htmlFor={inputId} style={style}>
      {control}
    </Field>
  );
}

In context

Combobox also appears on the forms reference cards, alongside the rest of its family.