Table

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)

Prüfungsgebühren November
MitgliedGurtBetragStatus
Lea Müller5. KupCHF 60.00Bezahlt
Noah Keller3. KupCHF 60.00Offen
Mia Brunner8. KupCHF 45.00Fehler 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)

Trainingsbesuche pro Gruppe, September
GruppeTrainingsBesucheØ pro Training
Tigers (4–6)89612
Kinder 7–10121’340111.7
Jugend 11–151220417
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.

FieldTypeRequiredDefaultDescription
captionstringrequired—Table caption, also the accessible name of the scroll region ("Prüfungsgebühren November").
min 1 chars
captionHiddenbooleanoptionalfalseHide the caption visually (when a heading right above already says the same); it stays available to screen readers.
columnsarray of objectsrequired—Columns in display order.
min 1 items · max 10 items
columns[].keystringrequired—Key into each row's cells.
pattern ^[A-Za-z][\w-]*$
columns[].labelstringrequired—Column header text; also the data-label shown in stacked mode.
min 1 chars
columns[].numericbooleanoptionalfalseRight-aligned, tabular figures (data-numeric).
rowsarray of objectsrequired—Rows in display order (already sorted by the app). For zero rows show an Empty state instead of the table.
rows[].idstringoptional—Stable id of the row's object; written to data-id for delegated event handling.
pattern ^[A-Za-z0-9_-]+$
rows[].hrefstringoptional—Detail page of the row; makes the row-header cell a link. Requires rowHeaderKey.
rows[].selectedbooleanoptionalfalseThe row is the selected/current one (e.g. shown in a detail pane next to the table): aria-current="true", accent-subtle background.
rows[].cellsobjectrequired—Map of column key → value: a string, a number, { belt: Belt data } or { badge: Badge data }. Missing keys render an empty cell.
rowHeaderKeystringoptional—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-]*$
stackbooleanoptionaltrueBelow 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.
localestringoptional"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

AttributeValuesDefaultDescription
role="region" tabindex="0" aria-labelcaption text—On .table-wrap: named, keyboard-scrollable region.
data-stack——On .table: rows become cards below 768 px.
data-densitydense—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-labelcolumn label—On every body cell: label shown in stacked mode.
data-idrow id—On tr: id of the row's object.

Parts

PartDescription
.tableThe <table>.
captionTitle 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 / .badgeStructured cells (see Belt badge, Status badge).

States

StateDescription
tbody tr:hoverRow 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" and th 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-label is a visual aid only.
  • One link per row at most (the row header); further actions go into a menu composed by the app.

Guidance

DoSort rows by what the admin needs next and show the sort in the UI.
DoShow the result count near the table ("18 von 312 Mitgliedern").
DoRight-align amounts and use the same currency format in every row.
Don'tDon't use a table for phone-first, act-on-one-item screens — use a list.
Don'tDon't put inline button toolboxes in rows.
Don'tDon't truncate silently: never cap results without "Mehr laden".

Related: List · Belt badge · Status badge · Empty state · Chip group