Inputs

Listbox

A visible list for choosing one or several options, for when a select would hide too much.

Built on
role="listbox"aria-activedescendantHidden form inputs
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
4.0 kB
Dependencies
React
Notify me about
Mentions and replies
Failed deploys
Weekly digest
Code
import { Listbox, ListboxOption } from "@/components/equip/listbox";

export default function Demo() {
  return (
    <Listbox label="Notify me about" multiple defaultValue={["mentions", "deploys"]} style={{ width: 216 }}>
      <ListboxOption value="mentions">Mentions and replies</ListboxOption>
      <ListboxOption value="deploys">Failed deploys</ListboxOption>
      <ListboxOption value="digest">Weekly digest</ListboxOption>
    </Listbox>
  );
}

Examples

Single choice with descriptions

Arrows move without choosing; Space or Enter picks, so browsing never changes the value by accident.

Region
eu-west-1Ireland, lowest latency for most of Europe
us-east-1N. Virginia, every service available
ap-southeast-2Sydney
me-central-1UAE, not available on your plan
Deploying to eu-west-1
Code
"use client";

import { useState } from "react";
import { Listbox, ListboxOption } from "@/components/equip/listbox";

export default function Example() {
  const [region, setRegion] = useState("eu-west-1");
  return (
    <div style={{ display: "grid", gap: 8, width: 260 }}>
      <Listbox label="Region" value={region} onValueChange={setRegion} name="region">
        <ListboxOption value="eu-west-1" description="Ireland, lowest latency for most of Europe">
          eu-west-1
        </ListboxOption>
        <ListboxOption value="us-east-1" description="N. Virginia, every service available">
          us-east-1
        </ListboxOption>
        <ListboxOption value="ap-southeast-2" description="Sydney">
          ap-southeast-2
        </ListboxOption>
        <ListboxOption value="me-central-1" description="UAE, not available on your plan" disabled>
          me-central-1
        </ListboxOption>
      </Listbox>
      <span style={{ fontSize: 12, color: "var(--eq-ink-2)" }}>Deploying to {region}</span>
    </div>
  );
}

Groups, several at once

Shift with an arrow extends the selection, Ctrl or Cmd with A selects every option, and typing jumps by name.

Share build logs with
Teams
Platform
Payments
Growth
People
Dana Whitfield
Kofi Mensah
Iris OkaforDeactivated
Code
import { Listbox, ListboxGroup, ListboxOption } from "@/components/equip/listbox";

export default function Example() {
  return (
    <Listbox
      label="Share build logs with"
      multiple
      defaultValue={["platform", "dana"]}
      name="share"
      style={{ width: 240 }}
    >
      <ListboxGroup label="Teams">
        <ListboxOption value="platform">Platform</ListboxOption>
        <ListboxOption value="payments">Payments</ListboxOption>
        <ListboxOption value="growth">Growth</ListboxOption>
      </ListboxGroup>
      <ListboxGroup label="People">
        <ListboxOption value="dana">Dana Whitfield</ListboxOption>
        <ListboxOption value="kofi">Kofi Mensah</ListboxOption>
        <ListboxOption value="iris" disabled description="Deactivated">
          Iris Okafor
        </ListboxOption>
      </ListboxGroup>
    </Listbox>
  );
}

Props

PropTypeDefaultWhat it does
labelReactNodenoneVisible label above the list. It names the listbox.
multiplebooleanfalseLets several options be chosen. Switches value and onValueChange to string[].
valuestring | string[]noneThe chosen value or values, when you control it.
defaultValuestring | string[]noneThe starting value when uncontrolled.
onValueChange(value: string) => void | (value: string[]) => voidnoneCalled with the new value, or every chosen value when multiple.
namestringnoneSubmits one hidden input per chosen value under this name.
disabledbooleanfalseTakes the list out of the tab order and ignores input.
childrenReactNodenoneListboxOption and ListboxGroup elements.
...restComponentProps<"div">noneEvery native div attribute, applied to the wrapper.
ListboxOption valuestringnoneThe value this option stands for.
ListboxOption descriptionReactNodenoneA quieter second line, read as the option's description.
ListboxOption disabledbooleannoneReachable with the arrows but cannot be chosen.
ListboxGroup labelReactNodenoneA heading that names the group.

Anatomy

root.eq-listbox
label.eq-listbox-label
list.eq-listbox-listThe focusable element with role="listbox".
option.eq-listbox-optiondata-active marks the option the keys act on.
box.eq-listbox-boxThe check box, in multiple mode.
check.eq-listbox-checkThe mark inside the box, drawn in when chosen.
tick.eq-listbox-tickThe tick on the chosen row, in single mode.
text.eq-listbox-text
option label.eq-listbox-option-labelType-ahead matches against this text.
description.eq-listbox-description
group.eq-listbox-group
group label.eq-listbox-group-label

Keyboard

ArrowDownArrowUpMoves to the next or previous option, without choosing it.
HomeEndMoves to the first or last option.
SpaceEnterSingle: chooses the option. Multiple: Space toggles it.
Shift+ArrowDownShift+ArrowUpMultiple: moves and adds that option to the selection.
Ctrl+ACmd+AMultiple: chooses every option, or clears them all when all are chosen.
A-ZMoves to the option whose label starts with the typed letters.

Notes

  • The list is one tab stop; aria-activedescendant points at the active option, so focus never leaves the list.
  • Selection does not follow focus, even in single mode: arrows browse and Space or Enter chooses.
  • Single select shows the chosen row in the solid on state with a tick; multiple select shows a check box per row, so the two never look alike.
  • Disabled options stay reachable so they can be read, as the listbox pattern allows, but cannot be chosen.
  • With name set, chosen values submit with the surrounding form like a native select multiple.

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/listbox.tsx308 lines
"use client";

import {
  createContext,
  useContext,
  useId,
  useRef,
  useState,
  type ComponentProps,
  type KeyboardEvent,
  type MouseEvent,
  type ReactNode,
} from "react";
import "./listbox.css";

type ListboxBase = Omit<ComponentProps<"div">, "defaultValue" | "onChange" | "children"> & {
  /** Visible label above the list. */
  label: ReactNode;
  /** Submits one hidden input per selected value under this name. */
  name?: string;
  disabled?: boolean;
  /** ListboxOption and ListboxGroup elements. */
  children: ReactNode;
};

type SingleProps = ListboxBase & {
  multiple?: false;
  value?: string | null;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
};

type MultipleProps = ListboxBase & {
  multiple: true;
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
};

export type ListboxProps = SingleProps | MultipleProps;

type ListboxContext = {
  multiple: boolean;
  selected: string[];
  activeId: string | null;
  pick: (id: string) => void;
};

const Context = createContext<ListboxContext>({
  multiple: false,
  selected: [],
  activeId: null,
  pick: () => {},
});

function toList(value: string | string[] | null | undefined) {
  if (value == null) return [];
  return Array.isArray(value) ? value : [value];
}

export function Listbox(props: ListboxProps) {
  const {
    label,
    multiple = false,
    value,
    defaultValue,
    onValueChange,
    name,
    disabled = false,
    className,
    children,
    ...rest
  } = props;
  const labelId = useId();
  const listRef = useRef<HTMLDivElement>(null);
  const typed = useRef({ text: "", at: 0 });
  const [internal, setInternal] = useState<string[]>(() => toList(defaultValue));
  const [activeId, setActiveId] = useState<string | null>(null);
  const selected = value !== undefined ? toList(value) : internal;

  function commit(next: string[]) {
    if (value === undefined) setInternal(next);
    if (props.multiple) props.onValueChange?.(next);
    else if (next[0] !== undefined) props.onValueChange?.(next[0]);
  }

  function options() {
    return Array.from(listRef.current?.querySelectorAll<HTMLElement>('[role="option"]') ?? []);
  }

  function usable(option: HTMLElement) {
    return option.getAttribute("aria-disabled") !== "true";
  }

  function choose(option: HTMLElement, mode: "set" | "toggle" | "add") {
    const optionValue = option.dataset.value;
    if (optionValue === undefined || !usable(option)) return;
    if (!multiple) {
      if (selected[0] !== optionValue) commit([optionValue]);
      return;
    }
    const has = selected.includes(optionValue);
    if (mode === "add" && has) return;
    commit(has && mode === "toggle" ? selected.filter((v) => v !== optionValue) : [...selected, optionValue]);
  }

  function moveTo(option: HTMLElement | undefined) {
    if (!option) return;
    setActiveId(option.id);
    option.scrollIntoView({ block: "nearest" });
  }

  function pick(id: string) {
    if (disabled) return;
    setActiveId(id);
    const option = options().find((o) => o.id === id);
    if (option) choose(option, "toggle");
  }

  function handleFocus() {
    if (activeId && options().some((o) => o.id === activeId)) return;
    const all = options();
    setActiveId((all.find((o) => o.dataset.value === selected[0]) ?? all[0])?.id ?? null);
  }

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (disabled) return;
    const all = options();
    if (all.length === 0) return;
    const index = all.findIndex((o) => o.id === activeId);
    const active = all[index];
    const mod = event.ctrlKey || event.metaKey;

    switch (event.key) {
      case "ArrowDown":
      case "ArrowUp": {
        event.preventDefault();
        const next =
          index < 0
            ? all[0]
            : all[Math.min(all.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)))];
        moveTo(next);
        if (multiple && event.shiftKey && next !== active) {
          const span = [active, next].filter((o): o is HTMLElement => !!o && usable(o)).map((o) => o.dataset.value ?? "");
          const added = span.filter((v) => !selected.includes(v));
          if (added.length) commit([...selected, ...added]);
        }
        return;
      }
      case "Home":
      case "End": {
        event.preventDefault();
        moveTo(event.key === "Home" ? all[0] : all[all.length - 1]);
        return;
      }
      case " ":
      case "Enter": {
        if (!active) return;
        if (event.key === "Enter" && multiple) return;
        event.preventDefault();
        choose(active, "toggle");
        return;
      }
      case "a":
      case "A": {
        if (!multiple || !mod) break;
        event.preventDefault();
        const values = all.filter(usable).map((o) => o.dataset.value ?? "");
        const everything = values.every((v) => selected.includes(v));
        commit(everything ? selected.filter((v) => !values.includes(v)) : [...new Set([...selected, ...values])]);
        return;
      }
    }

    if (event.key.length !== 1 || mod || event.altKey) return;
    if (event.key === " ") return;
    const memory = typed.current;
    memory.text = event.timeStamp - memory.at > 600 ? event.key.toLowerCase() : memory.text + event.key.toLowerCase();
    memory.at = event.timeStamp;
    const repeated = memory.text.length > 1 && [...memory.text].every((c) => c === memory.text[0]);
    const search = repeated ? memory.text[0] : memory.text;
    // A fresh search starts after the active option, so pressing one letter cycles through matches.
    const start = search.length === 1 ? index + 1 : Math.max(index, 0);
    for (let step = 0; step < all.length; step++) {
      const option = all[(start + step) % all.length];
      const text = option.querySelector(".eq-listbox-option-label")?.textContent ?? "";
      if (text.trim().toLowerCase().startsWith(search)) {
        event.preventDefault();
        moveTo(option);
        return;
      }
    }
  }

  return (
    <div
      {...rest}
      className={["eq-listbox", className].filter(Boolean).join(" ")}
      data-disabled={disabled || undefined}
    >
      <span id={labelId} className="eq-listbox-label">
        {label}
      </span>
      <div
        ref={listRef}
        role="listbox"
        className="eq-listbox-list"
        tabIndex={disabled ? -1 : 0}
        aria-labelledby={labelId}
        aria-multiselectable={multiple || undefined}
        aria-disabled={disabled || undefined}
        aria-activedescendant={activeId ?? undefined}
        onFocus={handleFocus}
        onKeyDown={handleKeyDown}
      >
        <Context.Provider value={{ multiple, selected, activeId, pick }}>{children}</Context.Provider>
      </div>
      {name &&
        selected.map((v) => <input key={v} type="hidden" name={name} value={v} disabled={disabled} />)}
    </div>
  );
}

export type ListboxOptionProps = Omit<ComponentProps<"div">, "children"> & {
  value: string;
  disabled?: boolean;
  /** A second, quieter line under the label. */
  description?: ReactNode;
  children: ReactNode;
};

export function ListboxOption({
  value,
  disabled,
  description,
  className,
  children,
  onClick,
  ...rest
}: ListboxOptionProps) {
  const { multiple, selected, activeId, pick } = useContext(Context);
  const id = useId();
  const isSelected = selected.includes(value);

  return (
    <div
      {...rest}
      id={id}
      role="option"
      className={["eq-listbox-option", className].filter(Boolean).join(" ")}
      data-value={value}
      data-active={activeId === id || undefined}
      aria-selected={isSelected}
      aria-disabled={disabled || undefined}
      aria-labelledby={`${id}label`}
      aria-describedby={description != null ? `${id}description` : undefined}
      onClick={(event: MouseEvent<HTMLDivElement>) => {
        onClick?.(event);
        if (!event.defaultPrevented) pick(id);
      }}
    >
      {multiple && (
        <span className="eq-listbox-box" aria-hidden="true">
          <svg className="eq-listbox-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
            <path d="M4 8.25l2.75 2.75L12 5.5" pathLength={1} />
          </svg>
        </span>
      )}
      <span className="eq-listbox-text">
        <span id={`${id}label`} className="eq-listbox-option-label">
          {children}
        </span>
        {description != null && (
          <span id={`${id}description`} className="eq-listbox-description">
            {description}
          </span>
        )}
      </span>
      {!multiple && (
        <svg className="eq-listbox-tick" 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" pathLength={1} />
        </svg>
      )}
    </div>
  );
}

export type ListboxGroupProps = Omit<ComponentProps<"div">, "children"> & {
  label: ReactNode;
  children: ReactNode;
};

export function ListboxGroup({ label, className, children, ...rest }: ListboxGroupProps) {
  const id = useId();
  return (
    <div
      {...rest}
      role="group"
      aria-labelledby={id}
      className={["eq-listbox-group", className].filter(Boolean).join(" ")}
    >
      <div id={id} className="eq-listbox-group-label">
        {label}
      </div>
      {children}
    </div>
  );
}
components/equip/listbox.css190 lines
@layer equip {
  .eq-listbox {
    display: flex;
    flex-direction: column;
    gap: var(--eq-space-1);
    min-width: 0;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-m) / 1.3 var(--eq-font);
  }

  .eq-listbox-label {
    font-size: var(--eq-text-s);
    font-weight: var(--eq-weight-strong);
    color: var(--eq-ink);
  }

  .eq-listbox-list {
    display: flex;
    flex-direction: column;
    gap: 2px;
    box-sizing: border-box;
    overflow: auto;
    padding: var(--eq-space-1);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    cursor: default;
  }

  /* The focus ring sits on the active option, not the list: that is where the keys act. */
  .eq-listbox-list:focus {
    outline: none;
  }

  .eq-listbox-list:focus-visible:not(:has([data-active])) {
    outline: 2px solid var(--eq-focus);
    outline-offset: -1px;
  }

  .eq-listbox[data-disabled] {
    opacity: 0.45;
  }

  .eq-listbox[data-disabled] .eq-listbox-list {
    cursor: not-allowed;
  }

  .eq-listbox-option {
    display: flex;
    align-items: center;
    gap: var(--eq-space-2);
    flex: none;
    box-sizing: border-box;
    min-height: var(--eq-h-s);
    padding: var(--eq-space-1) var(--eq-space-2);
    border-radius: var(--eq-radius-s);
    color: var(--eq-ink);
    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);
  }

  @media (hover: hover) {
    .eq-listbox:not([data-disabled]) .eq-listbox-option:hover:not([aria-disabled="true"]) {
      background: var(--eq-tint);
    }
  }

  .eq-listbox:not([data-disabled]) .eq-listbox-option:active:not([aria-disabled="true"]) {
    scale: var(--eq-press);
  }

  .eq-listbox-list:focus .eq-listbox-option[data-active]:not([aria-selected="true"]),
  .eq-listbox-list[aria-multiselectable]:focus .eq-listbox-option[data-active] {
    background: var(--eq-tint);
  }

  .eq-listbox-list:focus-visible .eq-listbox-option[data-active] {
    outline: 2px solid var(--eq-focus);
    outline-offset: -2px;
  }

  .eq-listbox-option[aria-disabled="true"] {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-listbox[data-disabled] .eq-listbox-option {
    cursor: not-allowed;
  }

  /* Single select: the chosen row is the solid "on" state with a tick drawn in. */
  .eq-listbox-list:not([aria-multiselectable]) .eq-listbox-option[aria-selected="true"],
  .eq-listbox-list:not([aria-multiselectable]) .eq-listbox-option[aria-selected="true"]:hover {
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
  }

  .eq-listbox-text {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-width: 0;
  }

  .eq-listbox-description {
    font-size: var(--eq-text-xs);
    color: var(--eq-ink-2);
  }

  .eq-listbox-list:not([aria-multiselectable]) .eq-listbox-option[aria-selected="true"] .eq-listbox-description {
    color: color-mix(in srgb, var(--eq-solid-ink) 72%, transparent);
  }

  .eq-listbox-tick,
  .eq-listbox-check {
    flex: none;
    width: 16px;
    height: 16px;
    opacity: 0;
    transition: opacity var(--eq-dur) var(--eq-ease);
  }

  /* Marks draw along their stroke (pathLength="1"); leaving, they fade and rewind quickly. */
  .eq-listbox-tick > path,
  .eq-listbox-check > path {
    stroke-dasharray: 2 3;
    stroke-dashoffset: 2;
    transition: stroke-dashoffset var(--eq-dur) var(--eq-ease);
  }

  .eq-listbox-option[aria-selected="true"] :is(.eq-listbox-tick, .eq-listbox-check) {
    opacity: 1;
  }

  .eq-listbox-option[aria-selected="true"] :is(.eq-listbox-tick, .eq-listbox-check) > path {
    stroke-dashoffset: 1;
    transition: stroke-dashoffset var(--eq-dur-move) var(--eq-spring);
  }

  /* Multiple select: a check box per row, as in the check control row of the shared look. */
  .eq-listbox-box {
    position: relative;
    flex: none;
    display: grid;
    place-items: center;
    box-sizing: border-box;
    width: 16px;
    height: 16px;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-s);
    background: var(--eq-raised);
    color: var(--eq-solid-ink);
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      border-color var(--eq-dur) var(--eq-ease);
  }

  .eq-listbox-box > .eq-listbox-check {
    position: absolute;
    inset: -1px;
  }

  .eq-listbox-option[aria-selected="true"] .eq-listbox-box {
    border-color: var(--eq-solid);
    background: var(--eq-solid);
  }

  .eq-listbox-group {
    display: flex;
    flex-direction: column;
    gap: 2px;
  }

  .eq-listbox-group + .eq-listbox-group {
    margin-top: var(--eq-space-1);
    padding-top: var(--eq-space-1);
    border-top: 1px solid var(--eq-line);
  }

  .eq-listbox-group-label {
    padding: var(--eq-space-1) var(--eq-space-2) 2px;
    color: var(--eq-ink-3);
    font-size: var(--eq-text-xs);
    font-weight: var(--eq-weight-strong);
  }
}

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