Segmented
All-in-one single-select segmented control. Apply segmented({ items }) to a wrapper element — it sets role="radiogroup" on the wrapper and generates role="radio" <button> options from the items array. The container has an inline pill style with a muted background.
Keyboard follows the WAI-ARIA APG Radio Group pattern: ArrowRight/ArrowDown move to and check the next segment, ArrowLeft/ArrowUp the previous (both wrap), Home/End jump to the first/last. Roving tabindex keeps a single tab stop on the group.
The selected key is published on a segmented context ({ value }) that descendants can read with node.getContext("segmented") — it is the same State the buttons write to, including when value was passed as a plain string or omitted.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | SegmentedItem[] | [] | Item definitions { label, key? }. label is a plain string (auto-wrapped) or any DomphyElement; key defaults to the item's zero-based index as a string. |
value | ValueOrState<string> | first item's key | Initially selected key. Pass a State to control selection externally. |
color | ThemeColor | "neutral" | Background tone of the pill container. |
accentColor | ThemeColor | "primary" | Color tone for the selected item. |
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 {
behavior,
type DomphyElement,
type ElementNode,
isState,
type Listener,
type PartialElement,
type State,
toState,
type ValueOrState,
} from "@domphy/core";
import {
type ThemeColor,
textToneOn,
themeColor,
themeSize,
themeSpacing,
} from "@domphy/theme";
import { horizontalArrowStep } from "../utils/direction.js";
import { elevation } from "../utils/elevation.js";
import { focusRing } from "../utils/focusRing.js";
/** One item inside a segmented control. */
type SegmentedItem = {
/** Button label — plain string (auto-wrapped) or any DomphyElement. */
label: string | DomphyElement;
/** Stable key. Defaults to the item's zero-based index. */
key?: string;
};
type SegmentedLive = {
items: SegmentedItem[];
color: ThemeColor;
accentColor: ThemeColor;
};
function persistValue<T>(
node: ElementNode,
key: string,
incoming: ValueOrState<T> | undefined,
fallback: T,
): State<T> {
if (isState(incoming)) {
node.setMetadata(key, incoming);
return incoming as State<T>;
}
let stored = node.getMetadata(key) as State<T> | undefined;
if (!stored) {
stored = toState((incoming as T | undefined) ?? fallback);
node.setMetadata(key, stored);
}
return stored;
}
function moveSegment(
e: Event,
items: SegmentedItem[],
key: string,
value: State<string>,
): void {
const k = (e as KeyboardEvent).key;
// WAI-ARIA APG radio group: both axes navigate (Right/Down = next,
// Left/Up = previous), Home/End jump to the ends.
if (
![
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
].includes(k)
)
return;
e.preventDefault();
const keys = items.map((item, index) => item.key ?? String(index));
const idx = keys.indexOf(key);
let next = idx;
if (k === "ArrowDown") next = (idx + 1) % keys.length;
else if (k === "ArrowUp") next = (idx - 1 + keys.length) % keys.length;
else if (k === "ArrowRight" || k === "ArrowLeft") {
const step = horizontalArrowStep(k, e.target as Element);
next = (idx + step + keys.length) % keys.length;
} else if (k === "Home") next = 0;
else if (k === "End") next = keys.length - 1;
value.set(keys[next]);
const group = (e.target as HTMLElement).closest("[role=radiogroup]");
group?.querySelectorAll<HTMLElement>("[role=radio]")[next]?.focus();
}
function buildSegmentButtons(
items: SegmentedItem[],
value: State<string>,
color: ThemeColor,
accentColor: ThemeColor,
): DomphyElement<"button">[] {
return items.map((item, index) => {
const key = item.key ?? String(index);
const labelEl: DomphyElement =
typeof item.label === "string"
? ({ span: item.label } as DomphyElement<"span">)
: item.label;
return {
button: [labelEl],
_key: key,
type: "button",
role: "radio",
ariaChecked: (l: Listener) => value.get(l) === key,
tabIndex: (l: Listener) => {
const current = value.get(l);
if (current === key) return 0;
if (current === "" && key === (items[0]?.key ?? "")) return 0;
return -1;
},
onClick: () => value.set(key),
onKeyDown: (e: Event) => moveSegment(e, items, key, value),
style: {
cursor: "pointer",
fontSize: (l: Listener) => themeSize(l, "inherit"),
// Floor height, label centred by flex: a FIXED height clipped the
// inherited line box whenever the host font size or line-height made
// it taller than the control (e.g. line-height 1.8 at 24px = 43.2px).
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minHeight: themeSpacing(6),
paddingBlock: 0,
paddingInline: themeSpacing(3),
border: "none",
borderRadius: themeSpacing(10),
color: (l: Listener) => themeColor(l, "text", color),
backgroundColor: "transparent",
transition:
"background-color 140ms ease, color 140ms ease, box-shadow 140ms ease",
// Labels track their fill (+3 hover, +2 press) off the track's own
// shift-2 anchor: the resting "text" tone measured 3.20:1 on the hover
// fill and 3.78:1 on the pressed fill (light), below WCAG AA.
"&:hover:not([disabled]):not([aria-checked=true])": {
backgroundColor: (l: Listener) => themeColor(l, "shift-3", color),
color: (l: Listener) => themeColor(l, textToneOn(3), color),
},
"&:active:not([disabled]):not([aria-checked=true])": {
backgroundColor: (l: Listener) => themeColor(l, "increase-2", color),
color: (l: Listener) => themeColor(l, textToneOn(3), color),
},
"&[aria-checked=true]": {
backgroundColor: (l: Listener) =>
themeColor(l, "shift-0", accentColor),
color: (l: Listener) => themeColor(l, "shift-10", accentColor),
// Selected segment sits slightly above the track.
boxShadow: elevation("low"),
},
"&:focus-visible": {
boxShadow: (l: Listener) => focusRing(l, accentColor),
},
"&[aria-checked=true]:focus-visible": {
boxShadow: (l: Listener) =>
`${elevation("low")}, ${focusRing(l, accentColor)}`,
},
"&[disabled]": {
opacity: 0.7,
cursor: "not-allowed",
},
},
} as DomphyElement<"button">;
});
}
/**
* All-in-one single-select segmented control. Generates `<button>` option
* elements from the `items` array. Apply to any wrapper element.
*
* @param props.items - Item definitions `{ label, key? }`.
* @param props.value - Initially selected key (value or State). Defaults to the first item's key.
* @param props.color - Theme color for the control background. Defaults to `"neutral"`.
* @param props.accentColor - Theme color for the selected item. Defaults to `"primary"`.
* @example
* { div: null, $: [segmented({ items: [
* { label: "Day", key: "day" },
* { label: "Month", key: "month" },
* { label: "Year", key: "year" },
* ] })] }
*/
function segmented(
props: {
items: SegmentedItem[];
value?: ValueOrState<string>;
color?: ThemeColor;
accentColor?: ThemeColor;
} = { items: [] },
): PartialElement {
const { items = [], color = "neutral", accentColor = "primary" } = props;
const live: SegmentedLive = { items, color, accentColor };
return {
role: "radiogroup",
_onSchedule: (node, element) => {
const value = persistValue(
node,
"segmentedValue",
props.value,
items[0]?.key ?? "",
);
// Publish the SAME state the buttons write to. A declared `_context`
// could not: the partial is node-agnostic, so it had to allocate its
// own `toState()` that never saw a click — and because ElementNode.patch
// re-merges `element._context` on every re-render, merging one State
// instance into another also clobbered the live notifier's listeners.
node.setContext("segmented", { value });
(element as Record<string, unknown>)[node.tagName] = buildSegmentButtons(
items,
value,
color,
accentColor,
);
},
...behavior<SegmentedLive>(
"segmented-items",
(node) => ({
update(snapshot) {
const value = persistValue(
node,
"segmentedValue",
props.value,
snapshot.items[0]?.key ?? "",
);
node.children.update(
buildSegmentButtons(
snapshot.items,
value,
snapshot.color,
snapshot.accentColor,
),
!!node.domElement,
true,
);
},
}),
live,
),
// Track is a soft surface anchor — shift via dataTone, paint with inherit.
dataTone: "shift-2",
style: {
display: "inline-flex",
paddingBlock: themeSpacing(1),
paddingInline: themeSpacing(1),
gap: themeSpacing(0.5),
borderRadius: themeSpacing(10),
backgroundColor: (l: Listener) => themeColor(l, "inherit", color),
color: (l: Listener) => themeColor(l, "text", color),
},
};
}
export { segmented };
export type { SegmentedItem };