Select Box
Use selectBox on a div element. It displays selected values as tags and opens a dropdown on click. The dropdown content is supplied by the caller — typically selectList + selectItem, which own the select context (not selectBox). Bind the same value to both selectBox (tag labels via options) and selectList (selection).
Unlike combobox, selectBox has no input field — it is suited for fixed option lists without search.
selectBox gives the dropdown panel a default surface (background, "border-strong" outline, density-scaled radius, medium elevation() shadow) so it's usable without the caller styling content itself.
The trigger is role="combobox" with aria-haspopup="listbox" — the WAI-ARIA APG Select-Only Combobox pattern. That role takes its accessible name from the author only, so the patch sets a default aria-label="Select"; declare your own aria-label/aria-labelledby on the host to replace it (native wins over patch).
Keyboard: Enter/Space toggle the dropdown; ArrowDown/Home open it and move focus to the first [role=option], ArrowUp/End to the last, and while it is open the same keys move focus between options (a selectList panel's own listbox model then takes over); Escape closes. Typing printable characters runs typeahead (Radix Select character-search parity): while closed, the first case-insensitive prefix match in options becomes the selection; while open, focus moves to the matching [role=option] in the panel. The buffer resets after 1s of idle, a repeated character cycles through that character's matches, and disabled options are skipped.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
content | DomphyElement | (required) | The popover/dropdown content element shown when open. Typically a div with selectList + selectItem children. |
options | Array<{ label: string; value: string }> | [] | Option list used to resolve selected values into tag labels displayed in the box. |
multiple | boolean | false | Allow multiple selection. When true, selected values are shown as removable tags and the popover stays open on item click. |
value | ValueOrState<Array<number | string | null | undefined> | number | string | null | undefined> | — | Bound selection value(s). |
placement | ValueOrState<Placement> | "bottom" | Floating placement of the dropdown popover. |
color | ThemeColor | "neutral" | Text and background tone of the trigger box. |
open | ValueOrState<boolean> | false | Controls whether the popover is open. Accepts a boolean, State, or Computed/ReadableState. When read-only, pass onDismiss. |
onDismiss | () => void | — | Called when the popover requests close. Required to close when open is read-only. |
Customization
Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.
There are four levels of customization, in increasing order of effort:
- Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
- Context attributes. Use
dataTone,dataSize, anddataDensityon a container to shift tone, size, or density for an entire subtree without touching individual elements. - Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
- Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Formulas
Unit - U = fontSize / 4 - convert final values with themeSpacing(n).
Size - n = intrinsic text lines, w = wrapping level, d = density factor:
height = (n * 6 + 2 * d * w) * U
paddingBlock = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius = d * w * UBase density d = 1.5:
| U | w=0 | w=1 | w=2 | w=3 |
|---|---|---|---|---|
height (n = 1) | 6 | 9 | 12 | 15 |
| paddingBlock | 0 | 1.5 | 3 | 4.5 |
| paddingInline | 3 | 4.5 | 6 | 4.5 |
| radius | 0 | 1.5 | 3 | 4.5 |
Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.
| Role | Shift | n=0 |
|---|---|---|
| Background | parent +/- n | 0 |
| Text | bg + K | 6 |
| Border | bg + K/2 | 3 |
| Hover | bg + 2K/3 | 4 |
| Selected / Focus | above +/- K/3 | 2-4 |
State shift range: K/3 <= delta <= 2K/3.
import {
type BehaviorInstance,
behavior,
type DomphyElement,
type ElementNode,
merge,
type PartialElement,
type State,
type StyleObject,
toState,
type ValueOrState,
} from "@domphy/core";
import type { Placement } from "@domphy/floating";
import {
type ThemeColor,
themeColor,
themeDensity,
themeSize,
themeSpacing,
} from "@domphy/theme";
import { elevation } from "../utils/elevation.js";
import { createFloating, floatingPanelId } from "../utils/floating.js";
import { focusRing } from "../utils/focusRing.js";
import { subscribeOpen } from "../utils/openState.js";
import { enabledOptionsIn } from "../utils/optionList.js";
import { tag } from "./tag.js";
// Typeahead (Radix Select character-search parity): printable characters
// accumulate into a buffer that resets after 1s of idle; the buffer is a
// case-insensitive prefix match against option labels. CLOSED trigger: the
// match becomes the selection (native <select> behavior). OPEN panel: focus
// moves to the matching [role=option] — focus is the keyboard highlight.
// A buffer of one repeated character ("aaa") cycles through that char's
// matches instead of pinning the first; disabled options are skipped.
const TYPEAHEAD_RESET_MS = 1000;
type TypeaheadMeta = {
buffer: string;
timer: ReturnType<typeof setTimeout> | null;
};
// The buffer lives on the NODE (not the factory closure): a selectBox inside
// a reactive parent gets a fresh closure per ancestor re-render, and a
// closure-local buffer would silently reset mid-typing on the reused node.
function resolveTypeahead(node: ElementNode): TypeaheadMeta {
let meta = node.getMetadata("selectBoxTypeahead") as
| TypeaheadMeta
| undefined;
if (!meta) {
meta = { buffer: "", timer: null };
node.setMetadata("selectBoxTypeahead", meta);
}
return meta;
}
const matchesPrefix = (label: string, needle: string) =>
label.trim().toLowerCase().startsWith(needle);
/**
* A clickable select trigger box that renders the currently selected option(s) as removable
* tags and toggles a floating popover (the dropdown content) anchored to itself. Selected
* labels are derived from `options` matching the bound `value`; removing a tag updates the value.
* Keyboard: Enter/Space toggle, ArrowDown/ArrowUp/Home/End open the listbox and move focus to
* the first/last option (and move between options while open), Escape closes, and printable
* characters typeahead-search options (closed: selects the match; open: focuses the matching
* `[role=option]` in the panel; a repeated character cycles matches).
*
* @hostTag div
* @param props.multiple - Whether multiple selection is allowed (renders removable tags and
* keeps the popover open on click). Defaults to `false`.
* @param props.value - Bound selection value(s). Accepts a value or reactive state of an array of
* `number | string | null | undefined`, or a single `number | string | null | undefined`.
* @param props.options - List of `{ label, value }` options used to resolve selected labels.
* Defaults to `[]`.
* @param props.placement - Floating placement of the dropdown popover. Accepts a value or
* reactive state. Defaults to `"bottom"`.
* @param props.content - Required. The popover/dropdown content element shown when open.
* @param props.color - Theme color tone for the box text/background. Defaults to `"neutral"`.
* @param props.open - Whether the popover is open. Accepts a value or reactive state (`ValueOrState<boolean>`, including `Computed`/`ReadableState`). Defaults to `false`. When the source is read-only, pass `onDismiss` so dismiss can close.
* @param props.onDismiss - Called when the popover requests close. Optional. Required to close when `open` is a read-only `Computed`/`ReadableState`.
* @example { div: null, $: [selectBox({ content: { div: [...] }, options: [{ label: "A", value: "a" }] })] }
*/
function selectBox(props: {
multiple?: boolean;
value?: ValueOrState<
| Array<number | string | null | undefined>
| number
| string
| null
| undefined
>;
options?: Array<{ label: string; value: string }>;
placement?: ValueOrState<Placement>;
content: DomphyElement;
color?: ThemeColor;
open?: ValueOrState<boolean>;
onDismiss?: () => void;
}): PartialElement {
const {
options = [],
placement = "bottom",
color = "neutral",
open = false,
multiple = false,
} = props;
const state = toState(props.value);
const openState = toState(open);
const { show, hide, anchorPartial } = createFloating({
kind: "selectBox",
open: openState,
onDismiss: props.onDismiss,
placement: toState(placement),
content: props.content,
});
const popoverPartial: PartialElement = {
onClick: (_e, node) => !multiple && hide(node),
dataTone: "shift-0",
style: {
backgroundColor: (listener) => themeColor(listener, "inherit"),
// Surface contract (dataTone-surface-contract): a tone-anchored panel
// must declare BOTH background and text color. Page-matching shift-0
// (same as menu/selectList/dialog), not the inverted tooltip surface.
color: (listener) => themeColor(listener, "text"),
borderRadius: (listener) => themeSpacing(themeDensity(listener) * 2),
outline: (listener) =>
`1px solid ${themeColor(listener, "border-strong")}`,
outlineOffset: "-1px",
boxShadow: elevation("medium"),
},
};
merge(props.content, popoverPartial);
type InnerProps = {
options: Array<{ label: string; value: string }>;
multiple: boolean;
color: ThemeColor;
state: State<
| Array<number | string | null | undefined>
| number
| string
| null
| undefined
>;
};
const buildWrap = (inner: InnerProps): DomphyElement<"div"> => ({
div: (listener) => {
const val = inner.state.get(listener);
const vals = Array.isArray(val) ? val : [val];
const opts = inner.options.filter((opt) => vals.includes(opt.value));
return opts.map((opt) => ({
span: opt.label,
$: [tag({ color: inner.color, removable: inner.multiple })],
_key: opt.value,
_onRemove: (_node: ElementNode) => {
const cur = inner.state.get();
const curVals = Array.isArray(cur) ? cur : [cur];
const filter = curVals.filter((v) => v !== opt.value);
inner.multiple
? inner.state.set(filter as any)
: inner.state.set(filter[0] as any);
},
})) as DomphyElement<"span">[];
},
_key: "selectBoxWrap",
style: {
display: "flex",
flexWrap: "wrap",
gap: themeSpacing(1),
flex: 1,
} as StyleObject,
});
const attachInner = (
node: ElementNode,
_inner: InnerProps,
): BehaviorInstance<InnerProps> => {
return {
update(next) {
node.children.update([buildWrap(next)]);
},
};
};
const toggle = (node?: ElementNode) =>
openState.get() ? hide(node) : show(node);
const panelOf = (node: ElementNode): Element | null => {
const root = node.getRoot().domElement as Element | null;
return (
root?.querySelector(`#${floatingPanelId("selectBox", node)}`) ?? null
);
};
// WAI-ARIA APG "Select-Only Combobox": Down/Up/Home/End move focus into the
// listbox. Measured in Chromium before this: with the panel open, every
// arrow key was swallowed by the typeahead branch's `key.length === 1`
// guard and focus never left the trigger, so the dropdown was reachable
// only by mouse or by guessing an option's first letter. Once focus is on
// an option, selectList's own listbox handler takes over.
const moveIntoPanel = (node: ElementNode, toLast: boolean): void => {
const options = enabledOptionsIn(panelOf(node));
if (!options.length) return;
const target = options[toLast ? options.length - 1 : 0]!;
target.focus();
target.scrollIntoView?.({ block: "nearest" });
};
// show() is debounced by 100ms and flips `openState` only AFTER mounting the
// panel, so waiting on that flip is exact where a fixed delay is not. The
// extra frame is required too: floating.ts drives the panel's `visibility`
// from the same state and that style write has not landed when the listener
// runs, so `focus()` would hit a `visibility: hidden` element and do nothing.
const openThenMove = (node: ElementNode, toLast: boolean): void => {
let release: (() => void) | undefined;
release = subscribeOpen(
openState,
(isOpen) => {
if (!isOpen) return;
release?.();
requestAnimationFrame(() => moveIntoPanel(node, toLast));
},
false,
);
show(node);
};
const typeahead = (key: string, node: ElementNode) => {
const meta = resolveTypeahead(node);
if (meta.timer) clearTimeout(meta.timer);
meta.buffer += key.toLowerCase();
meta.timer = setTimeout(() => {
meta.buffer = "";
}, TYPEAHEAD_RESET_MS);
// "aaa" cycles through the a* matches rather than matching "aaa" literally.
const repeated = meta.buffer.length > 1 && new Set(meta.buffer).size === 1;
const needle = repeated ? meta.buffer[0]! : meta.buffer;
if (openState.get()) {
// Panel is portaled under the root (see floating.ts); its id is
// deterministic, same lookup pattern as popover's onBlur guard.
const matches = enabledOptionsIn(panelOf(node)).filter((el) =>
matchesPrefix(el.textContent ?? "", needle),
);
if (!matches.length) return;
const active = node.domElement?.ownerDocument
?.activeElement as HTMLElement | null;
const from = repeated && active ? matches.indexOf(active) : -1;
const target = matches[(from + 1) % matches.length]!;
target.focus();
target.scrollIntoView?.({ block: "nearest" });
return;
}
// Closed trigger: selection follows typeahead. Single-select only — a
// multi-select tag set has no "current option" to move from.
if (multiple) return;
const matches = options.filter((opt) => matchesPrefix(opt.label, needle));
if (!matches.length) return;
const from = repeated
? matches.findIndex((opt) => opt.value === state.get())
: -1;
const next = matches[(from + 1) % matches.length]!;
state.set(next.value as any);
};
const partial: PartialElement = {
_onInsert: (node) => {
if (node.tagName !== "div") {
console.warn(`"selectBox" patch must use div tag`);
}
},
_onSchedule: (node, element) => {
(element as Record<string, unknown>)[node.tagName] = [
buildWrap({ options, multiple, color, state }),
];
},
...behavior<InnerProps>("selectBoxInner", attachInner, {
options,
multiple,
color,
state,
}),
onClick: (_e, node) => toggle(node),
// Focusable trigger: click + Enter/Space/ArrowDown open.
// Escape dismiss composes with createFloating's hide path.
tabindex: 0,
// WAI-ARIA APG "Select-Only Combobox": a non-editable div trigger that
// owns a listbox is role=combobox, not role=button. role=button also makes
// its subtree presentational, which broke the removable `tag()` buttons the
// multi-select trigger renders (axe `nested-interactive`).
role: "combobox",
// role=combobox takes its name from the author only — never from its
// content the way role=button did — so without a default every selectBox
// failed axe `aria-input-field-name`. A host-declared aria-label/
// aria-labelledby still wins (native over patch).
ariaLabel: "Select",
// Reactive boolean (same pattern as popover) — do NOT pin a static
// ariaExpanded or patch re-apply will overwrite after keyboard open.
ariaExpanded: (listener) => openState.get(listener),
ariaHaspopup: "listbox",
ariaControls: (listener) =>
listener?.elementNode
? floatingPanelId("selectBox", listener.elementNode)
: undefined,
onKeyDown: (e, node) => {
const key = (e as KeyboardEvent).key;
if (key === "Escape") {
hide(node);
return;
}
if (key === "Enter" || key === " ") {
e.preventDefault();
toggle(node);
return;
}
if (
key === "ArrowDown" ||
key === "ArrowUp" ||
key === "Home" ||
key === "End"
) {
e.preventDefault();
const toLast = key === "ArrowUp" || key === "End";
openState.get()
? moveIntoPanel(node, toLast)
: openThenMove(node, toLast);
return;
}
// Character typeahead (Radix Select parity) — modifier chords are
// shortcuts, not search input.
const kb = e as KeyboardEvent;
if (key.length === 1 && !kb.ctrlKey && !kb.metaKey && !kb.altKey) {
typeahead(key, node);
}
},
style: {
cursor: "pointer",
display: "flex",
alignItems: "center",
// Line box + paddingBlock, no height floor: the fake field is exactly as
// tall as the real one (inputText) it stands beside. A real <input> keeps
// a line box when it is empty; this div holds only chips, so with nothing
// selected it had none and collapsed to its padding — a zero-width space
// is the strut that gives it the one line every text field has.
paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1),
"&::before": { content: '"\\200b"', width: 0, overflow: "hidden" },
minWidth: themeSpacing(32),
outlineOffset: "-1px",
outline: (listener) =>
`1px solid ${themeColor(listener, "border-strong", "neutral")}`,
paddingInline: (listener) => themeSpacing(themeDensity(listener) * 2),
borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1.5),
fontSize: (listener) => themeSize(listener, "inherit"),
color: (listener) => themeColor(listener, "text", color),
backgroundColor: (listener) => themeColor(listener, "inherit", color),
transition:
"background-color 140ms ease, outline-color 140ms ease, box-shadow 140ms ease",
"&:focus-visible": {
boxShadow: (listener) => focusRing(listener, color),
},
},
};
merge(anchorPartial, partial);
return anchorPartial;
}
export { selectBox };