Tabs
Use the tabs patch to customize this element.
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 * U
Base 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.
<div class="blocks">
<div class="block active" data-tab="0">
import {
merge,
type PartialElement,
toState,
type ValueOrState,
} from "@domphy/core";
/**
* Container patch that establishes a `tabs` context (with a shared `activeKey`
* state) and the `tablist` role for child `tab`/`tabPanel` patches. No host tag
* check; typically applied to a wrapper element.
*
* @param props.activeKey - Initially active tab key. Optional, accepts a value or state of `number | string`. Defaults to `0`.
* @example { div: null, $: [tabs({ activeKey: 0 })] }
*/
function tabs(
props: { activeKey?: ValueOrState<number | string> } = {},
): PartialElement {
const partial: PartialElement = {
role: "tablist",
_onSchedule: (_node, element) => {
const partial = {
_context: {
tabs: {
activeKey: toState(props.activeKey || 0),
},
},
};
merge(element, partial);
},
};
return partial;
}
export { tabs };
</div>
<div class="block" data-tab="1">
import type { ElementNode, PartialElement } from "@domphy/core";
import {
type ThemeColor,
themeColor,
themeDensity,
themeSize,
themeSpacing,
} from "@domphy/theme";
/**
* Styles a single tab trigger inside a `tabs` tablist on the host `<button>` element.
* Wires up the tab's id/aria-controls/aria-selected, click selection, and
* arrow/Home/End keyboard navigation via the surrounding `tabs` context.
* Must be used inside a `tabs` patch.
*
* @hostTag button
* @param props.accentColor - Theme color for the active/focus underline. Optional. Defaults to `"primary"`.
* @param props.color - Theme color for the resting/hover underline and text. Optional. Defaults to `"neutral"`.
* @example { button: "Tab 1", $: [tab()] }
*/
function tab(
props: { accentColor?: ThemeColor; color?: ThemeColor } = {},
): PartialElement {
const { accentColor = "primary", color = "neutral" } = props;
const partial: PartialElement = {
role: "tab",
_onInsert: (node) => {
if (node.tagName !== "button") {
console.warn(`"tab" patch must use button tag`);
}
const context = node.getContext("tabs");
if (!context) {
console.warn(`"tab" patch must be used inside a "tabs"`);
return;
}
let children = (node.parent?.children.items ?? []) as ElementNode[];
children = children.filter(
(n) => n.type === "ElementNode" && n.attributes.get("role") === "tab",
);
const key =
node.key !== null && node.key !== undefined
? node.key
: children.indexOf(node);
const part: PartialElement = {
id: `tab${node.parent!.nodeId}${key}`,
ariaControls: `tabpanel${node.parent!.nodeId}${key}`,
ariaSelected: (listener) => context.activeKey.get(listener) === key,
onClick: () => context.activeKey.set(key),
onKeyDown: (e: Event) => {
const k = (e as KeyboardEvent).key;
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(k)) return;
e.preventDefault();
const tabs = (node.parent?.children.items ?? []).filter(
(n) =>
n.type === "ElementNode" &&
(n as ElementNode).attributes.get("role") === "tab",
) as ElementNode[];
const idx = tabs.indexOf(node);
let next = idx;
if (k === "ArrowRight") next = (idx + 1) % tabs.length;
else if (k === "ArrowLeft")
next = (idx - 1 + tabs.length) % tabs.length;
else if (k === "Home") next = 0;
else if (k === "End") next = tabs.length - 1;
const target = tabs[next];
context.activeKey.set(target.key ?? next);
(target.domElement as HTMLElement)?.focus();
},
};
node.merge(part);
},
style: {
cursor: "pointer",
fontSize: (listener) => themeSize(listener, "inherit"),
height: (listener) => themeSpacing(6 + themeDensity(listener) * 2),
paddingInline: (listener) => themeSpacing(themeDensity(listener) * 4),
border: "none",
outline: "none",
color: (listener) => themeColor(listener, "shift-9"),
backgroundColor: (listener) => themeColor(listener, "inherit"),
boxShadow: (listener) =>
`inset 0 -${themeSpacing(0.5)} 0 0 ${themeColor(listener, "shift-1", color)}`,
"&:hover:not([disabled])": {
boxShadow: (listener) =>
`inset 0 -${themeSpacing(0.5)} 0 0 ${themeColor(listener, "shift-2", color)}`,
},
"&[aria-selected=true]:not([disabled])": {
boxShadow: (listener) =>
`inset 0 -${themeSpacing(0.5)} 0 0 ${themeColor(listener, "shift-6", accentColor)}`,
},
"&:focus-visible": {
boxShadow: (listener) =>
`inset 0 -${themeSpacing(0.5)} 0 0 ${themeColor(listener, "shift-6", accentColor)}`,
},
},
};
return partial;
}
export { tab };
</div>
<div class="block" data-tab="2">
import type { ElementNode, PartialElement } from "@domphy/core";
import { themeDensity, themeSpacing } from "@domphy/theme";
/**
* Styles a tab panel inside a `tabs` tablist. Wires up the panel's
* id/aria-labelledby and toggles `hidden` based on the surrounding `tabs`
* context's active key. Must be used inside a `tabs` patch. Takes no props.
*
* @hostTag div
* @example { div: "Panel content", $: [tabPanel()] }
*/
function tabPanel(): PartialElement {
const partial: PartialElement = {
role: "tabpanel",
style: {
paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 2),
paddingInline: (listener) => themeSpacing(themeDensity(listener) * 2),
},
_onInsert: (node) => {
const context = node.getContext("tabs");
if (!context) {
console.warn(`"tabPanel" patch must be used inside a "tabs"`);
return;
}
let children = (node.parent?.children.items ?? []) as ElementNode[];
children = children.filter(
(n) =>
n.type === "ElementNode" && n.attributes.get("role") === "tabpanel",
);
const key =
node.key !== null && node.key !== undefined
? node.key
: children.indexOf(node);
const part: PartialElement = {
id: `tabpanel${node.parent!.nodeId}${key}`,
ariaLabelledby: `tab${node.parent!.nodeId}${key}`,
hidden: (listener) => context.activeKey.get(listener) !== key,
};
node.merge(part);
},
};
return partial;
}
export { tabPanel };
</div>
</div>