Select Box
Use selectBox on a div element. It displays selected values as tags and opens a dropdown on click. The dropdown content is built with selectList and selectItem — state flows automatically through context with no prop drilling.
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.
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. |
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 DomphyElement,
type ElementNode,
merge,
type PartialElement,
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 } from "../utils/floating.js";
import { focusRing } from "../utils/focusRing.js";
import { tag } from "./tag.js";
/**
* 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.
*
* @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. Defaults to `false`.
* @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>;
}): 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,
placement: toState(placement),
content: props.content,
});
const popoverPartial: PartialElement = {
onClick: (_e, node) => !multiple && hide(node),
dataTone: "shift-14",
style: {
backgroundColor: (listener) => themeColor(listener, "inherit"),
borderRadius: (listener) => themeSpacing(themeDensity(listener) * 2),
outline: (listener) =>
`1px solid ${themeColor(listener, "border-strong")}`,
outlineOffset: "-1px",
boxShadow: elevation("medium"),
},
};
merge(props.content, popoverPartial);
const wrap: DomphyElement<"div"> = {
div: (listener) => {
const val = state.get(listener);
const vals = Array.isArray(val) ? val : [val];
const opts = options.filter((opt) => vals.includes(opt.value));
return opts.map((opt) => ({
span: opt.label,
$: [tag({ color, removable: multiple })],
_key: opt.value,
_onRemove: (_node) => {
const cur = state.get();
const curVals = Array.isArray(cur) ? cur : [cur];
const filter = curVals.filter((v) => v !== opt.value);
multiple ? state.set(filter as any) : state.set(filter[0] as any);
},
})) as DomphyElement<"span">[];
},
style: {
display: "flex",
flexWrap: "wrap",
gap: themeSpacing(1),
flex: 1,
} as StyleObject,
};
const toggle = (node?: ElementNode) =>
openState.get() ? hide(node) : show(node);
const partial: PartialElement = {
_onInsert: (node) => {
if (node.tagName !== "div") {
console.warn(`"selectBox" patch must use div tag`);
}
},
_onInit: (node) => node.children.insert(wrap),
onClick: (_e, node) => toggle(node),
// Focusable trigger: click + Enter/Space/ArrowDown open (APG button+listbox).
// Escape dismiss composes with createFloating's hide path.
tabindex: 0,
role: "button",
// 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",
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" && !openState.get()) {
e.preventDefault();
show(node);
}
},
style: {
cursor: "pointer",
display: "flex",
alignItems: "center",
minHeight: (listener) => themeSpacing(6 + themeDensity(listener) * 2),
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 };