Data displaystable.table-wrap
Table
Compares many items across several columns (desktop admin); collapses to one card per row on phones.
Use a table when comparing or sorting across attributes is the task (payments, test applicants, imports). When people act on one item at a time, especially on phones, use a list instead (UX guide §12.1). Keep to ~7 columns.
The table lives in a scrollable, keyboard-focusable .table-wrap region named after the caption. Numeric columns (numeric: true) are right-aligned with tabular figures; numbers are formatted with Intl.NumberFormat(locale) (default de-CH). Pass pre-formatted strings for currency ("CHF 60.00").
With stack (default) each row becomes a card below 768 px: the rowHeaderKey column becomes the card title and every cell shows its column label (from data-label). With rowHeaderKey the first cell of each row is a <th scope="row">; add href to a row to make that cell a link (the one primary action of the row).
Cells are plain text or numbers. Two structured cell types are supported because they are the same everywhere: { belt: {…} } renders a Belt badge and { badge: {…} } a Status badge. Anything richer (menus, inline buttons, avatars) is composed by the app with other components — tables never accept HTML strings.
Examples
Payments with belt and status (stacks on mobile)
| Mitglied | Gurt | Betrag | Status |
|---|---|---|---|
| Lea Müller | 5. Kup | CHF 60.00 | Bezahlt |
| Noah Keller | 3. Kup | CHF 60.00 | Offen |
| Mia Brunner | 8. Kup | CHF 45.00 | Fehler in Webling |
Data
render({
"caption": "Prüfungsgebühren November",
"rowHeaderKey": "name",
"columns": [
{
"key": "name",
"label": "Mitglied"
},
{
"key": "belt",
"label": "Gurt"
},
{
"key": "amount",
"label": "Betrag",
"numeric": true
},
{
"key": "status",
"label": "Status"
}
],
"rows": [
{
"id": "m-1042",
"href": "#m-1042",
"cells": {
"name": "Lea Müller",
"belt": {
"belt": {
"label": "5. Kup",
"color": "green",
"tip": "blue"
}
},
"amount": "CHF 60.00",
"status": {
"badge": {
"label": "Bezahlt",
"tone": "success"
}
}
}
},
{
"id": "m-1077",
"href": "#m-1077",
"selected": true,
"cells": {
"name": "Noah Keller",
"belt": {
"belt": {
"label": "3. Kup",
"color": "blue"
}
},
"amount": "CHF 60.00",
"status": {
"badge": {
"label": "Offen",
"tone": "warning"
}
}
}
},
{
"id": "m-1103",
"href": "#m-1103",
"cells": {
"name": "Mia Brunner",
"belt": {
"belt": {
"label": "8. Kup",
"color": "yellow"
}
},
"amount": "CHF 45.00",
"status": {
"badge": {
"label": "Fehler in Webling",
"tone": "danger"
}
}
}
}
]
})Markup
<div class="table-wrap" role="region" tabindex="0" aria-label="Prüfungsgebühren November">
<table class="table" data-stack>
<caption>Prüfungsgebühren November</caption>
<thead><tr><th scope="col">Mitglied</th><th scope="col">Gurt</th><th scope="col" data-numeric>Betrag</th><th scope="col">Status</th></tr></thead>
<tbody><tr data-id="m-1042"><th scope="row" data-label="Mitglied"><a href="#m-1042">Lea Müller</a></th><td data-label="Gurt"><span class="belt" style="--belt-color: var(--belt-green); --belt-tip: var(--belt-blue)">5. Kup</span></td><td data-label="Betrag" data-numeric>CHF 60.00</td><td data-label="Status"><span class="badge" data-status="success"><svg class="icon" data-size="sm" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#check-circle"/></svg>Bezahlt</span></td></tr><tr data-id="m-1077" aria-current="true"><th scope="row" data-label="Mitglied"><a href="#m-1077">Noah Keller</a></th><td data-label="Gurt"><span class="belt" style="--belt-color: var(--belt-blue)">3. Kup</span></td><td data-label="Betrag" data-numeric>CHF 60.00</td><td data-label="Status"><span class="badge" data-status="warning"><svg class="icon" data-size="sm" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#alert-triangle"/></svg>Offen</span></td></tr><tr data-id="m-1103"><th scope="row" data-label="Mitglied"><a href="#m-1103">Mia Brunner</a></th><td data-label="Gurt"><span class="belt" style="--belt-color: var(--belt-yellow)">8. Kup</span></td><td data-label="Betrag" data-numeric>CHF 45.00</td><td data-label="Status"><span class="badge" data-status="danger"><svg class="icon" data-size="sm" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#alert-circle"/></svg>Fehler in Webling</span></td></tr></tbody>
</table>
</div>Dense numbers table (desktop)
| Gruppe | Trainings | Besuche | Ø pro Training |
|---|---|---|---|
| Tigers (4–6) | 8 | 96 | 12 |
| Kinder 7–10 | 12 | 1’340 | 111.7 |
| Jugend 11–15 | 12 | 204 | 17 |
Data
render({
"caption": "Trainingsbesuche pro Gruppe, September",
"rowHeaderKey": "group",
"density": "dense",
"columns": [
{
"key": "group",
"label": "Gruppe"
},
{
"key": "sessions",
"label": "Trainings",
"numeric": true
},
{
"key": "visits",
"label": "Besuche",
"numeric": true
},
{
"key": "avg",
"label": "Ø pro Training",
"numeric": true
}
],
"rows": [
{
"cells": {
"group": "Tigers (4–6)",
"sessions": 8,
"visits": 96,
"avg": 12
}
},
{
"cells": {
"group": "Kinder 7–10",
"sessions": 12,
"visits": 1340,
"avg": 111.7
}
},
{
"cells": {
"group": "Jugend 11–15",
"sessions": 12,
"visits": 204,
"avg": 17
}
}
]
})Markup
<div class="table-wrap" role="region" tabindex="0" aria-label="Trainingsbesuche pro Gruppe, September">
<table class="table" data-stack data-density="dense">
<caption>Trainingsbesuche pro Gruppe, September</caption>
<thead><tr><th scope="col">Gruppe</th><th scope="col" data-numeric>Trainings</th><th scope="col" data-numeric>Besuche</th><th scope="col" data-numeric>Ø pro Training</th></tr></thead>
<tbody><tr><th scope="row" data-label="Gruppe">Tigers (4–6)</th><td data-label="Trainings" data-numeric>8</td><td data-label="Besuche" data-numeric>96</td><td data-label="Ø pro Training" data-numeric>12</td></tr><tr><th scope="row" data-label="Gruppe">Kinder 7–10</th><td data-label="Trainings" data-numeric>12</td><td data-label="Besuche" data-numeric>1’340</td><td data-label="Ø pro Training" data-numeric>111.7</td></tr><tr><th scope="row" data-label="Gruppe">Jugend 11–15</th><td data-label="Trainings" data-numeric>12</td><td data-label="Besuche" data-numeric>204</td><td data-label="Ø pro Training" data-numeric>17</td></tr></tbody>
</table>
</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 |
|---|---|---|---|---|
caption | string | required | — | Table caption, also the accessible name of the scroll region ("Prüfungsgebühren November"). min 1 chars |
captionHidden | boolean | optional | false | Hide the caption visually (when a heading right above already says the same); it stays available to screen readers. |
columns | array of objects | required | — | Columns in display order. min 1 items · max 10 items |
columns[].key | string | required | — | Key into each row's cells.pattern ^[A-Za-z][\w-]*$ |
columns[].label | string | required | — | Column header text; also the data-label shown in stacked mode.min 1 chars |
columns[].numeric | boolean | optional | false | Right-aligned, tabular figures (data-numeric). |
rows | array of objects | required | — | Rows in display order (already sorted by the app). For zero rows show an Empty state instead of the table. |
rows[].id | string | optional | — | Stable id of the row's object; written to data-id for delegated event handling.pattern ^[A-Za-z0-9_-]+$ |
rows[].href | string | optional | — | Detail page of the row; makes the row-header cell a link. Requires rowHeaderKey. |
rows[].selected | boolean | optional | false | The row is the selected/current one (e.g. shown in a detail pane next to the table): aria-current="true", accent-subtle background. |
rows[].cells | object | required | — | Map of column key → value: a string, a number, { belt: Belt data } or { badge: Badge data }. Missing keys render an empty cell. |
rowHeaderKey | string | optional | — | Column whose cells name the row (<th scope="row">), usually the person or object name. Becomes the card title in stacked mode.pattern ^[A-Za-z][\w-]*$ |
stack | boolean | optional | true | Below 768 px, show each row as a card with labelled cells (data-stack). Turn off only for narrow tables that fit a phone. |
density | "default" | "dense" | optional | "default" | dense: rows --control-h-sm (36 px), smaller padding and --text-sm — applied from 1024 px only (design guide §5.2); desktop data tables only, ideally as a user choice. Never on phones. |
locale | string | optional | "de-CH" | Locale for formatting numeric cell values. pattern ^[a-z]{2}(-[A-Z]{2})?$ |
JSON Schema
{
"type": "object",
"additionalProperties": false,
"required": [
"caption",
"columns",
"rows"
],
"properties": {
"caption": {
"type": "string",
"minLength": 1,
"description": "Table caption, also the accessible name of the scroll region (\"Prüfungsgebühren November\")."
},
"captionHidden": {
"type": "boolean",
"default": false,
"description": "Hide the caption visually (when a heading right above already says the same); it stays available to screen readers."
},
"columns": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"description": "Columns in display order.",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"key",
"label"
],
"properties": {
"key": {
"type": "string",
"pattern": "^[A-Za-z][\\w-]*$",
"description": "Key into each row's `cells`."
},
"label": {
"type": "string",
"minLength": 1,
"description": "Column header text; also the `data-label` shown in stacked mode."
},
"numeric": {
"type": "boolean",
"default": false,
"description": "Right-aligned, tabular figures (`data-numeric`)."
}
}
}
},
"rows": {
"type": "array",
"description": "Rows in display order (already sorted by the app). For zero rows show an Empty state instead of the table.",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"cells"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]+$",
"description": "Stable id of the row's object; written to `data-id` for delegated event handling."
},
"href": {
"type": "string",
"description": "Detail page of the row; makes the row-header cell a link. Requires `rowHeaderKey`."
},
"selected": {
"type": "boolean",
"default": false,
"description": "The row is the selected/current one (e.g. shown in a detail pane next to the table): `aria-current=\"true\"`, accent-subtle background."
},
"cells": {
"type": "object",
"description": "Map of column key → value: a string, a number, `{ belt: Belt data }` or `{ badge: Badge data }`. Missing keys render an empty cell."
}
}
}
},
"rowHeaderKey": {
"type": "string",
"pattern": "^[A-Za-z][\\w-]*$",
"description": "Column whose cells name the row (`<th scope=\"row\">`), usually the person or object name. Becomes the card title in stacked mode."
},
"stack": {
"type": "boolean",
"default": true,
"description": "Below 768 px, show each row as a card with labelled cells (`data-stack`). Turn off only for narrow tables that fit a phone."
},
"density": {
"type": "string",
"enum": [
"default",
"dense"
],
"default": "default",
"description": "`dense`: rows `--control-h-sm` (36 px), smaller padding and `--text-sm` — applied from 1024 px only (design guide §5.2); desktop data tables only, ideally as a user choice. Never on phones."
},
"locale": {
"type": "string",
"pattern": "^[a-z]{2}(-[A-Z]{2})?$",
"default": "de-CH",
"description": "Locale for formatting numeric cell values."
}
}
}Markup & states
Root: div > table.table-wrap — usable without render() by writing the markup directly.
Attributes
| Attribute | Values | Default | Description |
|---|---|---|---|
role="region" tabindex="0" aria-label | caption text | — | On .table-wrap: named, keyboard-scrollable region. |
data-stack | — | — | On .table: rows become cards below 768 px. |
data-density | dense | — | On .table: 36 px rows, less padding, small text — from 1024 px only. |
aria-current="true" | — | — | On tr: the selected row (from selected). [aria-selected="true"] is styled the same for role="grid" tables composed by apps. |
data-numeric | — | — | On th/td: right-aligned tabular figures. |
data-label | column label | — | On every body cell: label shown in stacked mode. |
data-id | row id | — | On tr: id of the row's object. |
Parts
| Part | Description |
|---|---|
.table | The <table>. |
caption | Title of the table (optionally .u-visually-hidden). |
thead th[scope="col"] | Column headers. |
tbody th[scope="row"] | Row header cell (from rowHeaderKey), optionally containing the row link. |
.belt / .badge | Structured cells (see Belt badge, Status badge). |
States
| State | Description |
|---|---|
tbody tr:hover | Row highlight (--color-surface-2, pointer devices only). |
tbody tr[aria-current="true"] | Selected row: --color-accent-subtle background, accent-subtle text and a 3 px accent bar at the start. |
.table:not([data-stack]) th[scope="row"] | Below 768 px the row-header column stays sticky while the table scrolls horizontally. |
th[aria-sort] | Sortable column header with a button (app-provided; not rendered by render). |
Accessibility
- Real table semantics:
caption,th scope="col"andth scope="row", so screen readers announce header and row name for each cell. - The scroll wrapper is a named region with
tabindex="0"so keyboard users can scroll wide tables. - In stacked mode the header row is visually hidden but still in the accessibility tree;
data-labelis a visual aid only. - One link per row at most (the row header); further actions go into a menu composed by the app.
Guidance
Related: List · Belt badge · Status badge · Empty state · Chip group