Tabs

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

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.

FieldTypeRequiredDefaultDescription
idstringrequired—Id prefix for tabs and panels. Unique on the page.
pattern ^[A-Za-z][\w-]*$
labelstringrequired—Accessible name of the tab list (aria-label), e.g. "Event-Planung".
min 1 chars
selectedstringoptional—Key of the initially selected tab; defaults to the first.
pattern ^[A-Za-z0-9_-]+$
syncHashbooleanoptionalfalseKeep the selected tab in the URL hash (data-sync-hash) so it survives reload and back.
tabsarray of objectsrequired—Tabs in display order (2–6).
min 2 items · max 6 items
tabs[].keystringrequired—Stable key; part of the tab and panel ids.
pattern ^[A-Za-z0-9_-]+$
tabs[].labelstringrequired—Tab text, one or two words ("Aufgaben").
min 1 chars
tabs[].panelarray of stringoptional—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

AttributeValuesDefaultDescription
data-behavior="tabs"——Activates js/tabs.js on mount().
data-sync-hash——Selected tab kept in location.hash.
aria-selected / aria-controls / aria-labelledbyids—WAI-ARIA tab ↔ panel links.

Parts

PartDescription
.tabs__listrole="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

StateDescription
[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

FunctionSignatureReturnsDescription
mountmount(root: ParentNode): () => voidunmount functionActivates all behaviours below root, including tabs. Call the returned function when the view goes away.

Events

EventDetailDescription
clickon `[role="tab"]`Selects the tab and shows its panel.
keydownArrowLeft/ArrowRight (mirrored in RTL), Home, EndMoves 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 / tabpanel with aria-controls and aria-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-selected exposes it.

Guidance

DoKeep labels short and parallel ("Profil · Gurte · Rechnungen").
DoUse syncHash (or real routes) so sharing a link opens the same tab.
Don'tDon't use tabs for steps of a process — use the stepper.
Don'tDon't nest tabs in tabs.
Don'tDon't use more than 6 tabs.

Related: Page header · Stepper · Chip group