Tabs
All-in-one tabs patch. Apply tabs({ items }) to any wrapper element (div, section, …) — it generates a role="tablist" button row and one role="tabpanel" panel per item from the items array. Tab buttons support keyboard navigation (ArrowLeft / ArrowRight / Home / End). To control the active tab programmatically, pass an external State as activeKey and call .set() on it from outside.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | TabItem[] | [] | Tab definitions { label, content, key? }. label is a plain string (auto-wrapped) or any DomphyElement; content is the panel element rendered when the tab is active; key defaults to the item's zero-based index. |
activeKey | ValueOrState<string | number> | first item's key | Initially active key. Accepts a plain value or a reactive State. |
accentColor | ThemeColor | "primary" | Theme color for the active underline indicator. |
color | ThemeColor | "neutral" | Theme color for the resting underline. |
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 Listener,
type PartialElement,
toState,
type ValueOrState,
} from "@domphy/core";
import {
type ThemeColor,
themeColor,
themeDensity,
themeSize,
themeSpacing,
} from "@domphy/theme";
import { focusRing } from "../utils/focusRing.js";
/** One tab entry: a trigger label and its panel content. */
type TabItem = {
/** Tab button label — a plain string (auto-wrapped in span) or any DomphyElement. */
label: string | DomphyElement;
/** Panel content rendered when this tab is active. */
content: DomphyElement;
/** Stable identity key. Defaults to the item's zero-based index. */
key?: string | number;
};
/**
* All-in-one tabs patch. Generates a `[role=tablist]` button row and
* `[role=tabpanel]` panels from the `items` array. Apply to any wrapper
* element (`div`, `section`, …). No companion `tab()` / `tabPanel()` needed.
*
* To control the active tab programmatically, pass an external `State` as
* `activeKey` and call `.set()` on it from outside.
*
* @param props.items - Tab definitions `{ label, content, key? }`.
* @param props.activeKey - Initially active key (value or State). Defaults to the first item's key.
* @param props.accentColor - Theme color for the active underline indicator. Defaults to `"primary"`.
* @param props.color - Theme color for the resting underline. Defaults to `"neutral"`.
* @example
* { div: null, $: [tabs({ items: [
* { label: "Overview", content: { p: "Overview content" } },
* { label: "API", content: { p: "API content" } },
* ] })] }
*/
function tabs(
props: {
items: TabItem[];
activeKey?: ValueOrState<string | number>;
accentColor?: ThemeColor;
color?: ThemeColor;
} = { items: [] },
): PartialElement {
const { items = [], accentColor = "primary", color = "neutral" } = props;
const activeKey = toState(props.activeKey ?? items[0]?.key ?? 0);
return {
_onSchedule: (node, element) => {
const id = node.nodeId;
const buttons: DomphyElement<"button">[] = items.map((item, index) => {
const key = item.key ?? index;
const labelEl: DomphyElement =
typeof item.label === "string"
? ({ span: item.label } as DomphyElement<"span">)
: item.label;
return {
button: [labelEl],
_key: key,
type: "button",
role: "tab",
id: `tab${id}${key}`,
ariaControls: `panel${id}${key}`,
ariaSelected: (l: Listener) => activeKey.get(l) === key,
// Roving tabindex (APG tabs pattern): only the selected tab is in
// the tab order; arrow keys move both selection and focus.
tabIndex: (l: Listener) => (activeKey.get(l) === key ? 0 : -1),
onClick: () => activeKey.set(key),
onKeyDown: (e: Event) => {
const k = (e as KeyboardEvent).key;
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(k)) return;
e.preventDefault();
const keys = items.map((it, i) => it.key ?? i);
const idx = keys.indexOf(key);
let next = idx;
if (k === "ArrowRight") next = (idx + 1) % keys.length;
else if (k === "ArrowLeft")
next = (idx - 1 + keys.length) % keys.length;
else if (k === "Home") next = 0;
else if (k === "End") next = keys.length - 1;
activeKey.set(keys[next]);
// Focus must follow selection: without this the next arrow key
// fires on the OLD tab and re-navigates from its stale position,
// making later tabs unreachable by keyboard. Resolved via the
// event target's own tablist — shadow-root safe (unlike a
// document.getElementById lookup).
const list = (e.target as HTMLElement).closest("[role=tablist]");
list?.querySelectorAll<HTMLElement>("[role=tab]")[next]?.focus();
},
style: {
cursor: "pointer",
fontSize: (l: Listener) => themeSize(l, "inherit"),
height: (l: Listener) => themeSpacing(6 + themeDensity(l) * 2),
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 4),
border: "none",
outline: "none",
// Resting tabs: shift-13 for ≥4.5:1 on surface (catalog low-contrast).
color: (l: Listener) => themeColor(l, "shift-13"),
backgroundColor: (l: Listener) => themeColor(l, "inherit"),
boxShadow: (l: Listener) =>
`inset 0 ${themeSpacing(-0.5)} 0 0 ${themeColor(l, "shift-1", color)}`,
transition: "box-shadow 140ms ease, color 140ms ease",
"&:hover:not([disabled]):not([aria-selected=true])": {
color: (l: Listener) => themeColor(l, "shift-13"),
boxShadow: (l: Listener) =>
`inset 0 ${themeSpacing(-0.5)} 0 0 ${themeColor(l, "shift-2", color)}`,
},
"&[aria-selected=true]:not([disabled])": {
color: (l: Listener) => themeColor(l, "shift-13", accentColor),
boxShadow: (l: Listener) =>
`inset 0 ${themeSpacing(-0.5)} 0 0 ${themeColor(l, "shift-8", accentColor)}`,
},
// Focus ring must compose with the selected underline (both use
// box-shadow) so keyboard focus doesn't erase the active indicator.
"&:focus-visible": {
boxShadow: (l: Listener) => focusRing(l, accentColor),
},
"&[aria-selected=true]:focus-visible": {
boxShadow: (l: Listener) =>
`${focusRing(l, accentColor)}, inset 0 ${themeSpacing(-0.5)} 0 0 ${themeColor(l, "shift-6", accentColor)}`,
},
},
} as DomphyElement<"button">;
});
const tablist: DomphyElement<"div"> = {
div: buttons,
role: "tablist",
ariaOrientation: "horizontal",
style: { display: "flex" },
} as DomphyElement<"div">;
const panels: DomphyElement<"div">[] = items.map((item, index) => {
const key = item.key ?? index;
return {
div: [item.content],
_key: key,
role: "tabpanel",
id: `panel${id}${key}`,
ariaLabelledby: `tab${id}${key}`,
hidden: (l: Listener) => activeKey.get(l) !== key,
style: {
paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 2),
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 2),
},
} as DomphyElement<"div">;
});
// Inject generated structure into the host element's children slot.
(element as any)[node.tagName] = [tablist, ...panels];
},
};
}
export { tabs };
export type { TabItem };