Inputs

Combobox

A text input that suggests matching options as you type, for picking from a long list or typing freely.

Built on
Popover APIrole=comboboxaria-activedescendant@starting-style
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
5.1 kB
Dependencies
React
Try its options

Loading its options.

Code
import { Combobox } from "@/components/equip/combobox";

const regions = ["eu-west-1", "eu-west-2", "eu-central-1", "us-east-1", "us-east-2", "us-west-2", "ap-south-1", "ap-northeast-1"];

export default function Demo() {
  return (
    <div style={{ width: 216 }}>
      <Combobox label="Region" options={regions} placeholder="Search regions" name="region" />
    </div>
  );
}
Your versionchange its options in Look, This part

Examples

Starting value and hint

Open it with the button or ArrowDown: the full list shows, with a check on the current value.

Used for digest emails and scheduled reports.

Code
import { Combobox } from "@/components/equip/combobox";

const zones = [
  "Europe/London",
  "Europe/Berlin",
  "Europe/Lisbon",
  "America/New_York",
  "America/Chicago",
  "America/Los_Angeles",
  "Asia/Kolkata",
  "Asia/Tokyo",
  "Australia/Sydney",
];

export default function Example() {
  return (
    <div style={{ width: "100%", maxWidth: 280 }}>
      <Combobox
        label="Time zone"
        hint="Used for digest emails and scheduled reports."
        options={zones}
        defaultValue="Europe/Berlin"
        name="timezone"
      />
    </div>
  );
}

Controlled

onValueChange fires for typed text too, so check membership when only listed values count.

No owner yet

Code
"use client";

import { useState } from "react";
import { Combobox } from "@/components/equip/combobox";

const owners = ["Platform team", "Payments team", "Growth team", "Security team", "Data platform"];

export default function Example() {
  const [owner, setOwner] = useState("");
  const known = owners.includes(owner);
  return (
    <div style={{ display: "grid", gap: 8, width: "100%", maxWidth: 280 }}>
      <Combobox label="Service owner" options={owners} value={owner} onValueChange={setOwner} placeholder="Type a team" />
      <p style={{ margin: 0, fontSize: 13 }}>
        {owner === "" ? "No owner yet" : known ? `Pages go to ${owner}` : `"${owner}" is not a known team`}
      </p>
    </div>
  );
}

Inside a clipping container

This card has overflow: hidden; the list still shows in full because it renders in the top layer.

Code
import { Combobox } from "@/components/equip/combobox";

const languages = ["TypeScript", "JavaScript", "Python", "Go", "Rust", "Kotlin", "Swift", "Ruby"];

export default function Example() {
  return (
    <div style={{ boxSizing: "border-box", width: "100%", maxWidth: 306, height: 96, padding: 12, overflow: "hidden", border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-l)" }}>
      <Combobox label="Primary language" options={languages} placeholder="Choose or type" />
    </div>
  );
}

Props

PropTypeDefaultWhat it does
labelReactNodenoneVisible label, tied to the input and naming the list.
hintReactNodenoneHelp text under the control, linked with aria-describedby.
errorReactNodenoneError text under the control. Marks the input invalid and is announced with it.
optionsstring[]noneSuggestions, filtered by what is typed. Each must be unique.
valuestringnoneControlled text of the input.
defaultValuestring""Uncontrolled starting text.
onValueChange(value: string) => voidnoneCalled on every change of the text, typed, picked from the list or cleared with Escape.
emptyTextstring"No matches"Row shown, and announced, when nothing matches.
...restComponentProps<"input">noneEvery native input attribute, including name, placeholder, required and ref. className and style go to the wrapper.

Anatomy

root.eq-comboboxWrapper that carries className and style.
label.eq-combobox-label
control.eq-combobox-controlThe bordered box holding the input and the button.
input.eq-combobox-inputThe text input with role combobox.
button.eq-combobox-buttonOpens the full list; out of the tab order.
hint.eq-combobox-hint
error.eq-combobox-error
popover.eq-combobox-popoverThe floating surface, in the top layer while open.
list.eq-combobox-listThe listbox.
option.eq-combobox-option
option label.eq-combobox-option-label
check.eq-combobox-checkMarks the option that equals the current text.
empty.eq-combobox-emptyShown in place of options when nothing matches.
status.eq-combobox-statusVisually hidden live region with the match count.

Keyboard

ArrowDownOpens the list on the first option, or moves to the next option, wrapping to the first.
ArrowUpOpens the list on the last option, or moves to the previous option, wrapping to the last.
Alt+ArrowDownOpens the list without moving into it.
Alt+ArrowUpCloses the list.
EnterPicks the highlighted option and closes the list. With none highlighted, closes the list and lets the form submit.
EscapeCloses the list; when it is already closed, clears the text.
ArrowLeftArrowRightHomeEndMove the caret in the text and leave the list.
TabCloses the list and moves on, keeping the typed text.

Notes

  • Follows the APG editable combobox with list autocomplete: focus stays in the input and aria-activedescendant names the highlighted option.
  • Typing filters options that contain the text anywhere, ignoring case.
  • Free text is allowed. Validate it yourself if only listed options are acceptable.
  • The list is a manual popover in the top layer, so an overflow: hidden ancestor cannot clip it; it flips above the input when there is no room below.
  • The number of matches is announced politely as you type, including when there are none.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/combobox.tsx332 lines
"use client";

import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { ChangeEvent, ComponentProps, FocusEvent, KeyboardEvent, ReactNode, Ref } from "react";
import "./combobox.css";

export type ComboboxProps = Omit<ComponentProps<"input">, "value" | "defaultValue" | "type"> & {
  /** Visible label. */
  label: ReactNode;
  /** Help text under the control, linked with aria-describedby. */
  hint?: ReactNode;
  /** Error text. When set, the input is marked invalid and the text is announced with it. */
  error?: ReactNode;
  /** Suggestions, filtered by what is typed. */
  options: string[];
  /** Controlled text of the input. */
  value?: string;
  /** Uncontrolled starting text. */
  defaultValue?: string;
  /** Called on every change of the text, typed or picked from the list. */
  onValueChange?: (value: string) => void;
  /** Row shown when nothing matches. */
  emptyText?: string;
};

/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
  return node != null && node !== false && node !== "";
}

function setRefs<T>(node: T, ...refs: (Ref<T> | undefined)[]) {
  for (const ref of refs) {
    if (typeof ref === "function") ref(node);
    else if (ref) ref.current = node;
  }
}

function supportsPopover(el: HTMLElement) {
  return typeof el.showPopover === "function";
}

const GAP = 4;

export function Combobox({
  label,
  hint,
  error,
  options,
  value,
  defaultValue = "",
  onValueChange,
  emptyText = "No matches",
  className,
  style,
  id,
  disabled,
  readOnly,
  ref,
  onChange,
  onKeyDown,
  onBlur,
  "aria-describedby": describedBy,
  "aria-invalid": ariaInvalid,
  ...rest
}: ComboboxProps) {
  const autoId = useId();
  const inputId = id ?? autoId;
  const labelId = `${autoId}-label`;
  const listId = `${autoId}-list`;
  const hintId = `${autoId}-hint`;
  const errorId = `${autoId}-error`;
  const optionId = (index: number) => `${autoId}-option-${index}`;

  const inputRef = useRef<HTMLInputElement | null>(null);
  const controlRef = useRef<HTMLDivElement | null>(null);
  const popoverRef = useRef<HTMLDivElement | null>(null);
  const mergedRef = useCallback((node: HTMLInputElement | null) => setRefs(node, inputRef, ref), [ref]);

  const [inner, setInner] = useState(defaultValue);
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(-1);
  /** What filters the list; null shows every option, as after a pick or on the button. */
  const [query, setQuery] = useState<string | null>(null);

  const text = value ?? inner;
  const needle = (query ?? "").trim().toLowerCase();
  const matches = needle ? options.filter((o) => o.toLowerCase().includes(needle)) : options;
  const locked = disabled || readOnly;
  // The options prop can shrink under an open list; never point at a row that is gone.
  const activeIndex = active < matches.length ? active : -1;

  function setText(next: string) {
    if (value === undefined) setInner(next);
    onValueChange?.(next);
  }

  function show(index: number) {
    setOpen(true);
    setActive(index);
  }

  function hide() {
    setOpen(false);
    setActive(-1);
  }

  function pick(option: string) {
    setText(option);
    setQuery(null);
    hide();
  }

  // Put the list in the top layer, so no overflow: hidden ancestor clips it,
  // and pin it under the control while the page scrolls or resizes.
  useLayoutEffect(() => {
    const popover = popoverRef.current;
    const control = controlRef.current;
    if (!popover || !control) return;
    // Without the Popover API the hidden attribute alone shows and hides the list.
    const native = supportsPopover(popover);
    if (!open) {
      if (native && popover.matches(":popover-open")) popover.hidePopover();
      return;
    }
    if (native && !popover.matches(":popover-open")) popover.showPopover();

    function place() {
      if (!popover || !control) return;
      const rect = control.getBoundingClientRect();
      popover.style.left = `${rect.left}px`;
      popover.style.width = `${rect.width}px`;
      // Measured after the width is set, since the width decides the height.
      const height = popover.offsetHeight;
      const below = rect.bottom + GAP;
      const fitsBelow = below + height <= window.innerHeight;
      const fitsAbove = rect.top - GAP - height >= 0;
      const above = !fitsBelow && fitsAbove;
      popover.style.top = `${above ? rect.top - GAP - height : below}px`;
      // Tells the stylesheet which way the list grows, for its entrance.
      popover.dataset.side = above ? "top" : "bottom";
    }

    place();
    window.addEventListener("scroll", place, true);
    window.addEventListener("resize", place);
    return () => {
      window.removeEventListener("scroll", place, true);
      window.removeEventListener("resize", place);
    };
  }, [open, matches.length]);

  useEffect(() => {
    if (!open || activeIndex < 0) return;
    document.getElementById(optionId(activeIndex))?.scrollIntoView({ block: "nearest" });
  });

  function handleChange(event: ChangeEvent<HTMLInputElement>) {
    onChange?.(event);
    const next = event.currentTarget.value;
    setText(next);
    setQuery(next);
    show(-1);
  }

  function handleBlur(event: FocusEvent<HTMLInputElement>) {
    onBlur?.(event);
    hide();
  }

  function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented || locked || event.nativeEvent.isComposing) return;
    const count = matches.length;
    switch (event.key) {
      case "ArrowDown":
        event.preventDefault();
        if (event.altKey) show(-1);
        else if (!open) show(count ? 0 : -1);
        else setActive(count ? (activeIndex + 1) % count : -1);
        return;
      case "ArrowUp":
        event.preventDefault();
        if (event.altKey) hide();
        else if (!open) show(count - 1);
        else setActive(count ? (activeIndex <= 0 ? count - 1 : activeIndex - 1) : -1);
        return;
      case "Enter":
        if (open && activeIndex >= 0) {
          // The pick takes Enter, so a surrounding form does not submit.
          event.preventDefault();
          pick(matches[activeIndex]);
        } else if (open) {
          hide();
        }
        return;
      case "Escape":
        if (open) {
          event.preventDefault();
          hide();
        } else if (text !== "") {
          event.preventDefault();
          setText("");
          setQuery(null);
        }
        return;
      case "ArrowLeft":
      case "ArrowRight":
      case "Home":
      case "End":
        // The caret moves in the text, so visual focus returns there too.
        setActive(-1);
        return;
    }
  }

  const status = !open
    ? ""
    : matches.length === 0
      ? emptyText
      : `${matches.length} ${matches.length === 1 ? "option" : "options"} available.`;

  return (
    <div className={["eq-combobox", className].filter(Boolean).join(" ")} style={style}>
      <label className="eq-combobox-label" id={labelId} htmlFor={inputId}>
        {label}
      </label>
      <div className="eq-combobox-control" ref={controlRef} data-disabled={disabled || undefined}>
        <input
          autoComplete="off"
          autoCapitalize="none"
          spellCheck={false}
          {...rest}
          ref={mergedRef}
          id={inputId}
          className="eq-combobox-input"
          type="text"
          role="combobox"
          aria-autocomplete="list"
          aria-expanded={open}
          aria-controls={listId}
          aria-activedescendant={open && activeIndex >= 0 ? optionId(activeIndex) : undefined}
          aria-describedby={
            [describedBy, present(hint) ? hintId : null, present(error) ? errorId : null].filter(Boolean).join(" ") ||
            undefined
          }
          aria-invalid={present(error) ? true : ariaInvalid}
          disabled={disabled}
          readOnly={readOnly}
          value={text}
          onChange={handleChange}
          onBlur={handleBlur}
          onKeyDown={handleKeyDown}
        />
        <button
          type="button"
          className="eq-combobox-button"
          tabIndex={-1}
          aria-label="Show options"
          aria-expanded={open}
          aria-controls={listId}
          disabled={locked}
          onMouseDown={(event) => {
            // Keep focus in the input, which owns the keyboard.
            event.preventDefault();
            inputRef.current?.focus();
          }}
          onClick={() => {
            if (open) hide();
            else {
              setQuery(null);
              show(-1);
            }
          }}
        >
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <path d="M4.5 6.5L8 10l3.5-3.5" />
          </svg>
        </button>
      </div>
      {present(hint) && (
        <p className="eq-combobox-hint" id={hintId}>
          {hint}
        </p>
      )}
      {present(error) && (
        <p className="eq-combobox-error" id={errorId}>
          {error}
        </p>
      )}
      <div
        ref={popoverRef}
        className="eq-combobox-popover"
        popover="manual"
        hidden={!open}
        // A press inside the list must not blur the input, or the list closes before the click lands.
        onMouseDown={(event) => event.preventDefault()}
      >
        <ul className="eq-combobox-list" id={listId} role="listbox" aria-labelledby={labelId}>
          {matches.map((option, index) => (
            <li
              key={option}
              id={optionId(index)}
              className="eq-combobox-option"
              role="option"
              aria-selected={index === activeIndex}
              data-current={option === text || undefined}
              onClick={() => pick(option)}
            >
              <span className="eq-combobox-option-label">{option}</span>
              <svg
                className="eq-combobox-check"
                viewBox="0 0 16 16"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.5"
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="M3.5 8.5l3 3 6-7" />
              </svg>
            </li>
          ))}
        </ul>
        {matches.length === 0 && <div className="eq-combobox-empty">{emptyText}</div>}
      </div>
      <div className="eq-combobox-status" role="status" aria-live="polite">
        {status}
      </div>
    </div>
  );
}
components/equip/combobox.css271 lines
@layer equip {
  .eq-combobox {
    display: grid;
    gap: var(--eq-space-1);
    min-width: 0;
  }

  .eq-combobox-label {
    justify-self: start;
    color: var(--eq-ink);
    font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
  }

  .eq-combobox-hint,
  .eq-combobox-error {
    margin: 0;
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
  }

  .eq-combobox-hint {
    color: var(--eq-ink-2);
  }

  .eq-combobox-error {
    color: var(--eq-danger);
  }

  /* The bordered box is the control the eye sees; the input inside is bare. */
  .eq-combobox-control {
    box-sizing: border-box;
    display: flex;
    align-items: stretch;
    height: var(--eq-h-m);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    color: var(--eq-ink);
    transition: border-color var(--eq-dur) var(--eq-ease);
  }

  @media (hover: hover) {
    /* :where keeps this below the invalid rule, so a red border stays red on hover. */
    .eq-combobox-control:hover:where(:not([data-disabled])) {
      border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
    }
  }

  .eq-combobox-control:has(.eq-combobox-input:focus-visible) {
    outline: 2px solid var(--eq-focus);
    outline-offset: -1px;
  }

  .eq-combobox-control:has(.eq-combobox-input:user-invalid, .eq-combobox-input[aria-invalid="true"]) {
    border-color: var(--eq-danger);
  }

  .eq-combobox-input {
    flex: 1 1 auto;
    min-width: 0;
    margin: 0;
    padding: 0 0 0 var(--eq-space-3);
    border: 0;
    border-radius: inherit;
    background: transparent;
    color: inherit;
    font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font);
  }

  .eq-combobox-input:focus-visible {
    outline: none;
  }

  .eq-combobox-input::placeholder {
    color: var(--eq-ink-3);
    opacity: 1;
  }

  .eq-combobox-button {
    display: grid;
    flex: none;
    place-items: center;
    width: calc(var(--eq-h-m) - 2px);
    margin: 0;
    padding: 0;
    border: 0;
    border-start-end-radius: calc(var(--eq-radius-m) - 1px);
    border-end-end-radius: calc(var(--eq-radius-m) - 1px);
    background: transparent;
    color: var(--eq-ink-2);
    cursor: pointer;
    transition: color var(--eq-dur) var(--eq-ease);
  }

  .eq-combobox-button > svg {
    width: 16px;
    height: 16px;
    transition:
      rotate var(--eq-dur-move) var(--eq-spring),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-combobox-button[aria-expanded="true"] > svg {
    rotate: 180deg;
  }

  /* The button is part of the box, so its chevron gives rather than the whole button. */
  .eq-combobox-button:active:not(:disabled) > svg {
    scale: calc(var(--eq-press) - 0.04);
  }

  @media (hover: hover) {
    .eq-combobox-button:hover:not(:disabled) {
      color: var(--eq-ink);
    }
  }

  .eq-combobox-control[data-disabled] {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-combobox-control[data-disabled] > * {
    cursor: not-allowed;
  }

  /* The list floats in the top layer. Script sets top, left and width from the control. */
  .eq-combobox-popover {
    box-sizing: border-box;
    position: fixed;
    inset: auto;
    max-height: calc(var(--eq-h-s) * 7 + var(--eq-space-1) * 2);
    margin: 0;
    padding: var(--eq-space-1);
    overflow-y: auto;
    overscroll-behavior: contain;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-l);
    background: var(--eq-raised);
    box-shadow: var(--eq-shadow);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
    color: var(--eq-ink);
    opacity: 1;
    scale: 1;
    translate: 0 0;
    /* Grows out of the control: from its top edge when below it, its bottom edge when above. */
    transform-origin: top center;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring),
      translate var(--eq-dur-move) var(--eq-spring),
      display var(--eq-dur) allow-discrete,
      overlay var(--eq-dur) allow-discrete;
  }

  .eq-combobox-popover[data-side="top"] {
    transform-origin: bottom center;
  }

  /* Leaving is quicker than arriving, and does not spring. */
  .eq-combobox-popover[hidden] {
    display: none;
    opacity: 0;
    scale: 0.96;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease),
      translate var(--eq-dur) var(--eq-ease),
      display var(--eq-dur) allow-discrete,
      overlay var(--eq-dur) allow-discrete;
  }

  @starting-style {
    .eq-combobox-popover:not([hidden]) {
      opacity: 0;
      scale: 0.96;
      translate: 0 -4px;
    }

    .eq-combobox-popover[data-side="top"]:not([hidden]) {
      translate: 0 4px;
    }
  }

  .eq-combobox-list {
    display: grid;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .eq-combobox-option {
    display: flex;
    align-items: center;
    gap: var(--eq-space-2);
    min-height: var(--eq-h-s);
    padding-inline: var(--eq-space-2);
    border-radius: var(--eq-radius-s);
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-m) / 1.2 var(--eq-font);
    cursor: pointer;
    user-select: none;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-combobox-option:active {
    scale: var(--eq-press);
  }

  .eq-combobox-option-label {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .eq-combobox-check {
    flex: none;
    width: 16px;
    height: 16px;
    visibility: hidden;
  }

  .eq-combobox-option[data-current] .eq-combobox-check {
    visibility: visible;
  }

  @media (hover: hover) {
    .eq-combobox-option:hover {
      background: var(--eq-tint);
    }
  }

  /* The row under visual focus, the one aria-activedescendant names. Tinted like a focused menu item. */
  .eq-combobox-option[aria-selected="true"] {
    background: var(--eq-tint-strong);
  }

  /* The option that equals the current text is the selected row. */
  .eq-combobox-option[data-current] {
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
  }

  .eq-combobox-option[data-current][aria-selected="true"] {
    background: color-mix(in srgb, var(--eq-solid) 85%, var(--eq-solid-ink));
  }

  .eq-combobox-empty {
    display: flex;
    align-items: center;
    min-height: var(--eq-h-s);
    padding-inline: var(--eq-space-2);
    color: var(--eq-ink-2);
    font: var(--eq-weight) var(--eq-text-s) / 1.2 var(--eq-font);
  }

  .eq-combobox-status {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index