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
- eu-west-1
- eu-west-2
- eu-central-1
- us-east-1
- us-east-2
- us-west-2
- ap-south-1
- ap-northeast-1
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.
- Europe/London
- Europe/Berlin
- Europe/Lisbon
- America/New_York
- America/Chicago
- America/Los_Angeles
- Asia/Kolkata
- Asia/Tokyo
- Australia/Sydney
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.
- Platform team
- Payments team
- Growth team
- Security team
- Data platform
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
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label, tied to the input and naming the list. |
hint | ReactNode | none | Help text under the control, linked with aria-describedby. |
error | ReactNode | none | Error text under the control. Marks the input invalid and is announced with it. |
options | string[] | none | Suggestions, filtered by what is typed. Each must be unique. |
value | string | none | Controlled text of the input. |
defaultValue | string | "" | Uncontrolled starting text. |
onValueChange | (value: string) => void | none | Called on every change of the text, typed, picked from the list or cleared with Escape. |
emptyText | string | "No matches" | Row shown, and announced, when nothing matches. |
...rest | ComponentProps<"input"> | none | Every native input attribute, including name, placeholder, required and ref. className and style go to the wrapper. |
Anatomy
| root | .eq-combobox | Wrapper that carries className and style. |
|---|---|---|
| label | .eq-combobox-label | |
| control | .eq-combobox-control | The bordered box holding the input and the button. |
| input | .eq-combobox-input | The text input with role combobox. |
| button | .eq-combobox-button | Opens the full list; out of the tab order. |
| hint | .eq-combobox-hint | |
| error | .eq-combobox-error | |
| popover | .eq-combobox-popover | The floating surface, in the top layer while open. |
| list | .eq-combobox-list | The listbox. |
| option | .eq-combobox-option | |
| option label | .eq-combobox-option-label | |
| check | .eq-combobox-check | Marks the option that equals the current text. |
| empty | .eq-combobox-empty | Shown in place of options when nothing matches. |
| status | .eq-combobox-status | Visually hidden live region with the match count. |
Keyboard
| ArrowDown | Opens the list on the first option, or moves to the next option, wrapping to the first. |
|---|---|
| ArrowUp | Opens the list on the last option, or moves to the previous option, wrapping to the last. |
| Alt+ArrowDown | Opens the list without moving into it. |
| Alt+ArrowUp | Closes the list. |
| Enter | Picks the highlighted option and closes the list. With none highlighted, closes the list and lets the form submit. |
| Escape | Closes the list; when it is already closed, clears the text. |
| ArrowLeftArrowRightHomeEnd | Move the caret in the text and leave the list. |
| Tab | Closes 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;
}
}