Navigationstable.tabs
Tabs
Switches between a few views of the same object or area within one page.
Use tabs for 2–6 sibling views of one thing ("Aufgaben · Material · Helfer" of an event). Beyond ~6, or when views are separate areas, use page navigation instead. The tab list scrolls horizontally rather than wrapping.
render outputs the WAI-ARIA tabs markup with deterministic ids ({id}-tab-{key}, {id}-panel-{key}); the selected tab's panel is visible, the others are hidden. The keyboard behaviour comes from js/tabs.js via data-behavior="tabs".
Panel content from data is plain text paragraphs (a short summary). Apps fill richer panel content themselves: render into #{id}-panel-{key} after mounting. Never pass HTML through data.
Deep links: with syncHash, the selected tab is written to the URL hash (#{id}-tab-{key}) and restored on reload and back. For object sub-views that deserve their own route (UX guide §3.2 "each tab has its own URL"), prefer real routes and render the tab list as links in the app.
Examples
Event planning
3 offene Aufgaben · 1 überfällig
12 von 15 Positionen bereit
7 von 9 Schichten besetzt
Data
render({
"id": "event",
"label": "Event-Planung",
"tabs": [
{
"key": "tasks",
"label": "Aufgaben",
"panel": [
"3 offene Aufgaben · 1 überfällig"
]
},
{
"key": "material",
"label": "Material",
"panel": [
"12 von 15 Positionen bereit"
]
},
{
"key": "helpers",
"label": "Helfer",
"panel": [
"7 von 9 Schichten besetzt"
]
}
]
})Markup
<div class="tabs" data-behavior="tabs">
<div role="tablist" class="tabs__list" aria-label="Event-Planung"><button type="button" role="tab" id="event-tab-tasks" aria-controls="event-panel-tasks" aria-selected="true">Aufgaben</button><button type="button" role="tab" id="event-tab-material" aria-controls="event-panel-material" aria-selected="false" tabindex="-1">Material</button><button type="button" role="tab" id="event-tab-helpers" aria-controls="event-panel-helpers" aria-selected="false" tabindex="-1">Helfer</button></div>
<div role="tabpanel" id="event-panel-tasks" aria-labelledby="event-tab-tasks" tabindex="0"><p>3 offene Aufgaben · 1 überfällig</p></div><div role="tabpanel" id="event-panel-material" aria-labelledby="event-tab-material" tabindex="0" hidden><p>12 von 15 Positionen bereit</p></div><div role="tabpanel" id="event-panel-helpers" aria-labelledby="event-tab-helpers" tabindex="0" hidden><p>7 von 9 Schichten besetzt</p></div>
</div>Synced to the URL hash, second tab selected
6. Kup seit 14.05.2026. Nächste Prüfung: 14.11.
Data
render({
"id": "member",
"label": "Mitglied Lea Müller",
"selected": "belts",
"syncHash": true,
"tabs": [
{
"key": "profile",
"label": "Profil"
},
{
"key": "belts",
"label": "Gurte",
"panel": [
"6. Kup seit 14.05.2026. Nächste Prüfung: 14.11."
]
},
{
"key": "invoices",
"label": "Rechnungen"
}
]
})Markup
<div class="tabs" data-behavior="tabs" data-sync-hash>
<div role="tablist" class="tabs__list" aria-label="Mitglied Lea Müller"><button type="button" role="tab" id="member-tab-profile" aria-controls="member-panel-profile" aria-selected="false" tabindex="-1">Profil</button><button type="button" role="tab" id="member-tab-belts" aria-controls="member-panel-belts" aria-selected="true">Gurte</button><button type="button" role="tab" id="member-tab-invoices" aria-controls="member-panel-invoices" aria-selected="false" tabindex="-1">Rechnungen</button></div>
<div role="tabpanel" id="member-panel-profile" aria-labelledby="member-tab-profile" tabindex="0" hidden></div><div role="tabpanel" id="member-panel-belts" aria-labelledby="member-tab-belts" tabindex="0"><p>6. Kup seit 14.05.2026. Nächste Prüfung: 14.11.</p></div><div role="tabpanel" id="member-panel-invoices" aria-labelledby="member-tab-invoices" tabindex="0" hidden></div>
</div>Data contract
What render(data) accepts — validated against this JSON Schema in development and tests. Unknown fields are rejected. The same schema is in components.json.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | — | Id prefix for tabs and panels. Unique on the page. pattern ^[A-Za-z][\w-]*$ |
label | string | required | — | Accessible name of the tab list (aria-label), e.g. "Event-Planung".min 1 chars |
selected | string | optional | — | Key of the initially selected tab; defaults to the first. pattern ^[A-Za-z0-9_-]+$ |
syncHash | boolean | optional | false | Keep the selected tab in the URL hash (data-sync-hash) so it survives reload and back. |
tabs | array of objects | required | — | Tabs in display order (2–6). min 2 items · max 6 items |
tabs[].key | string | required | — | Stable key; part of the tab and panel ids. pattern ^[A-Za-z0-9_-]+$ |
tabs[].label | string | required | — | Tab text, one or two words ("Aufgaben"). min 1 chars |
tabs[].panel | array of string | optional | — | Plain-text paragraphs of the panel. Leave empty when the app renders the panel content itself. |
JSON Schema
{
"type": "object",
"additionalProperties": false,
"required": [
"id",
"label",
"tabs"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[A-Za-z][\\w-]*$",
"description": "Id prefix for tabs and panels. Unique on the page."
},
"label": {
"type": "string",
"minLength": 1,
"description": "Accessible name of the tab list (`aria-label`), e.g. \"Event-Planung\"."
},
"selected": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]+$",
"description": "Key of the initially selected tab; defaults to the first."
},
"syncHash": {
"type": "boolean",
"default": false,
"description": "Keep the selected tab in the URL hash (`data-sync-hash`) so it survives reload and back."
},
"tabs": {
"type": "array",
"minItems": 2,
"maxItems": 6,
"description": "Tabs in display order (2–6).",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"key",
"label"
],
"properties": {
"key": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]+$",
"description": "Stable key; part of the tab and panel ids."
},
"label": {
"type": "string",
"minLength": 1,
"description": "Tab text, one or two words (\"Aufgaben\")."
},
"panel": {
"type": "array",
"description": "Plain-text paragraphs of the panel. Leave empty when the app renders the panel content itself.",
"items": {
"type": "string",
"description": "One paragraph."
}
}
}
}
}
}
}Markup & states
Root: div.tabs — usable without render() by writing the markup directly.
Attributes
| Attribute | Values | Default | Description |
|---|---|---|---|
data-behavior="tabs" | — | — | Activates js/tabs.js on mount(). |
data-sync-hash | — | — | Selected tab kept in location.hash. |
aria-selected / aria-controls / aria-labelledby | ids | — | WAI-ARIA tab ↔ panel links. |
Parts
| Part | Description |
|---|---|
.tabs__list | role="tablist" with aria-label. |
[role="tab"] | <button> with id {id}-tab-{key}; only the selected one is in the tab order. |
[role="tabpanel"] | Panel with id {id}-panel-{key}, tabindex="0", hidden unless selected. |
States
| State | Description |
|---|---|
[role="tab"][aria-selected="true"] | Accent text + 2 px accent underline. |
[role="tabpanel"][hidden] | Inactive panel. |
Behaviour & events
Module: js/tabs.js
Markup has data-behavior="tabs"; call mount(root) from js/behaviors.js once after inserting it (idempotent; returns unmount).
API
| Function | Signature | Returns | Description |
|---|---|---|---|
mount | mount(root: ParentNode): () => void | unmount function | Activates all behaviours below root, including tabs. Call the returned function when the view goes away. |
Events
| Event | Detail | Description |
|---|---|---|
click | on `[role="tab"]` | Selects the tab and shows its panel. |
keydown | ArrowLeft/ArrowRight (mirrored in RTL), Home, End | Moves to and activates the previous/next/first/last tab (automatic activation). |
history.replaceState | `#{tab id}` (with `data-sync-hash`) | Selected tab written to the URL hash; on mount a matching hash selects that tab. |
Accessibility
- WAI-ARIA tabs pattern:
tablist/tab/tabpanelwitharia-controlsandaria-labelledby. - Roving tab index: only the selected tab is in the tab order; arrow keys move between tabs.
- Panels have
tabindex="0"so text-only panels are reachable with Tab. - Selection shown by colour and an underline;
aria-selectedexposes it.
Guidance
syncHash (or real routes) so sharing a link opens the same tab.Related: Page header · Stepper · Chip group