Core · Show content
Table
Compares records across several attributes, in rows and columns that stay readable at 320 px.
Example
A table of three services with their status, CPU and region.
| Service | Status | CPU | Region |
|---|---|---|---|
| api-gateway | Running | 12% | eu-west-1 |
| data-pipeline | Degraded | 78% | us-east-1 |
| mailer | Stopped | 0% | eu-west-1 |
Make the window narrow: below 480 px of container width each row becomes a card.
Known issues
Sorting in the browser (data-luz-sort) is checked in the Chromium accessibility tree only, and not yet tested with NVDA, JAWS, VoiceOver, TalkBack or Orca. The sorted column is exposed with aria-sort on its column header and each sort is announced in a status message; tabbing through the headings announces the column name, and the sort state is read on the header cell and in the status message.
- Only the rows loaded in the page are sorted, the sort is not remembered on reload, and the address bar does not change. A table with several header rows or a
colspanheading is not supported, and anything in a sortable heading besides its text or one link is dropped when it becomes a button. - Sorting a few thousand rows moves them all in one task: sort them on the server. The order of text depends on the browser's collation for the language of the table.
Code
Copy the markup
A bare table needs no class: it fills its container.
<table>
<caption>Invoices</caption>
<thead>
<tr><th scope="col">Number</th><th scope="col">Date</th><th scope="col">Amount (€)</th></tr>
</thead>
<tbody>
<tr><td>INV-0042</td><td>1 Oct 2026</td><td>120.00</td></tr>
</tbody>
</table>
The classes add what a bare table cannot do: .luz-table-wrap scrolls a wide table, and .luz-table with data-label stacks its rows into cards. With the classes:
<div class="luz-table-wrap" role="region" aria-label="Invoices" tabindex="0">
<table class="luz-table">
<caption>Invoices</caption>
<thead>
<tr><th scope="col">Number</th><th scope="col">Date</th><th scope="col">Amount (€)</th></tr>
</thead>
<tbody>
<tr><td data-label="Number">INV-0042</td><td data-label="Date">1 Oct 2026</td><td data-label="Amount (€)">120.00</td></tr>
</tbody>
</table>
</div>
Variants and options
| Class or attribute | Effect |
|---|---|
.luz-table | Full width, header row on the strong surface, a rule between rows, a highlight on the row under the pointer |
.luz-table-wrap | Scrolls the table sideways when it is wider than its container |
.luz-table | Keeps the first column in view while the table scrolls sideways |
.luz-table | Less space above and below each row (about 33 px for a line of text, not 49 px) and a narrower gutter between columns; the type size does not change |
.luz-table on a <th> and its <td> | Aligns a column of numbers to the end of the cell, in figures of equal width |
data-luz-sort on a <th scope="col"> | The column can be sorted in the browser: its text becomes a button |
data-sort-value on a <td> | What the cell sorts by when its text is not that: a formatted number ( 1,204), a date shown as “13 Oct”, a price with its currency |
aria-sort on the sorted <th> | ascending or descending, on one heading only: a triangle shows the direction |
data-label on each <td> | The column name shown before the value when rows become cards |
Numbers and dense data take a class each. Put the numeric class on the heading as well as on every cell of the column.
| Route | Parcels | Weight (kg) |
|---|---|---|
| Sevilla to Madrid | 1,204 | 8,430.5 |
| Cádiz to Málaga | 312 | 1,912.0 |
| Huelva to Faro | 58 | 406.3 |
Sorting in the browser
Mark each sortable heading with data-luz-sort. The script turns its text into a button; the table keeps working without it.
| Route | Parcels | Weight (kg) | Notes |
|---|---|---|---|
| Cádiz to Málaga | 312 | 1,912.0 | Daily |
| Huelva to Faro | 58 | 406.3 | Monday and Thursday |
| Sevilla to Madrid | 1,204 | 8,430.5 | Daily |
<table class="luz-table" id="routes">
<caption>Parcels per route</caption>
<thead>
<tr>
<!-- With a link: it sorts on the server without JavaScript -->
<th scope="col" data-luz-sort><a href="?sort=route#routes">Route</a></th>
<!-- Plain text: the script makes it a button -->
<th scope="col" class="luz-table__numeric" data-luz-sort>Parcels</th>
</tr>
</thead>
<tbody>
<tr>
<td data-label="Route">Sevilla to Madrid</td>
<td class="luz-table__numeric" data-label="Parcels" data-sort-value="1204">1,204</td>
</tr>
</tbody>
</table>
- The cell's text is what it sorts by, unless it has
data-sort-value. Two plain numbers (12,0.5,-3) sort as numbers; anything else sorts as text, in the order of the table's language, withRoom 2beforeRoom 10. - A number with a thousands separator, a unit or a currency needs
data-sort-valuewith the plain number (1204for1,204), or it sorts as text and “1,204” falls before “999”. A date is an ISO date (2026-10-13), whatever it shows. - Equal values keep their order. The first press sorts ascending, the next descending, and so on; to go back to the first order, reload.
- The heading says the column and nothing else. No direction in its text and none in the caption: the triangle,
aria-sortand the status line say it, and they follow the sort.
How it works
A native <table> with <caption>, <thead> and <th scope>, styled by components/table.css. No JavaScript.
- The wrapper is a container: below 480 px of its own width, a container query turns each row into a card of label and value pairs and reduces the cell padding. It follows the width of the wrapper, not the window, so a narrow table in a wide page stacks too.
- The labels come from
data-label; a cell without one shows its value only. - A cell that holds more than one element needs a wrapper
<div>inside it, so the value stays together. .luz-table__numericsetstext-align: endandfont-variant-numeric: tabular-nums, so the digits of a column line up whatever the language. In the stacked card layout the value is already at the end of its row..luz-table--compactchanges only the padding of the cells: the type keeps its size, so the table is denser but no harder to read.- Sorted by the server, for paginated or filtered results and for every table without JavaScript: each heading is a link to the same page sorted by that column, and only the sorted column's
<th scope="col">carriesaria-sort="ascending"or"descending". Say the direction in the visible text of the link too, for example “Amount (€), ascending”; make the link's text start with the heading's own words; end itshrefwith the table'sid(?sort=amount&dir=desc#invoices) so the next Tab starts at the table after the page reloads; and state the sort in the caption (“Invoices, sorted by amount, ascending”). - Sorted in the browser, for a table that holds all its rows:
data-luz-sorton the headings.luz.jsloadstable-sort.json demand, only on a page that has such a heading, and each heading's text (or the content of its link) moves into a<button type="button">. A press sorts every<tbody>in place, writesaria-sorton that heading alone and says “Sorted by Parcels, ascending” in an<output>that reads politely and is hidden visually. Focus stays on the button. A table that sorts in the browser has no direction in its caption or headings: they would be wrong after one press. - The triangle after the sorted heading is drawn by CSS from
aria-sort(a shape, not text), so a server-sorted heading gets it too. Up is ascending, down is descending. - Below 480 px a heading row that holds a link or a button stays visible: its headings show as a row above the cards, and wrap, so the controls can be seen and reached. A heading row of plain text is hidden there, as before, because each card carries its labels.
- Cards change the
displayof the table elements, which can hide the table from some screen readers. The stylesheet recommendsrole="table","row"and"cell"on a table that stacks.
Without JavaScript
Nothing changes: scrolling, the sticky column and the cards are all CSS. A heading with a link sorts on the server; a plain heading is just a heading.
Rows are never hidden, and if the script does not load the table stays whole.
Loading the sort on its own
The module has no imports and no effect until you call it. Pass the t of core's strings so the status line is written in the table's language; without it the table still sorts and the status line stays empty.
import { initTableSort } from "@luz-ds/core/table-sort";
import { t } from "@luz-ds/core/i18n";
initTableSort(document, t);
Keyboard and ARIA
The wrapper takes focus (tabindex="0") so keyboard users can scroll it with the arrow keys.
- Give the wrapper
role="region"and anaria-label, so that focus stop has a name. scope="col"on column headers, andscope="row"on a header cell that names its row.- A sortable heading is a button: Tab reaches it, Enter or Space sorts, and focus stays on it while the rows move. A link that the script replaces keeps the focus it had.
aria-sortis on the one sorted<th scope="col">. Each sort is announced by the status line (<output>, rolestatus), in the language of the table; the strings aresortedAscandsortedDescof core's strings (see JavaScript modules;data-luz-label-sorted-ascanddata-luz-label-sorted-descoverride them).- The column name is the heading's text only: no arrow character, no “ascending” in the markup.
Contrast and focus are in the Design tab.
Design
When to use
Use a table to compare records across the same attributes: services and their status, invoices and their amounts.
When not to use
Do not use a table for layout.
- For one attribute per item, use a list.
- For a few items of equal weight, use cards.
- For a data grid (filters, selecting rows, editing cells or thousands of rows), use a library built for it. LUZ's table shows rows and sorts them; it does not manage them. For paginated results, sort on the server.
- For a trend over time, use a chart library and keep the table as its text alternative. A bar for each row is a meter in the cell of its figure.
Do and don't
Writing the content
The caption names the table; column headings are short nouns.
- Put units in the heading, not in every cell: "Amount (€)".
- Put the column that identifies the row first.
- Align numbers to the end with
.luz-table__numeric, on the heading and on every cell of the column, with the same number of decimals. Text stays at the start. - In a table that sorts in the browser, the heading is the column name and its unit, “Weight (kg)”: no arrow and no direction, in the heading or in the caption.
Contrast and focus
Headings sit on the strong surface; cells on the page, and on the hover surface under the pointer.
| Part | Colour on background | Light | Dark | High contrast | Needs |
|---|---|---|---|---|---|
Header cell | --luz-text-primary on --luz-surface-strong | 13.5:1 | 10.9:1 | 18.0:1 | 4.5:1 |
Cell | --luz-text-primary on --luz-surface-page | 15.8:1 | 15.1:1 | 21.0:1 | 4.5:1 |
Cell, hovered row | --luz-text-primary on --luz-surface-hover | 14.4:1 | 12.6:1 | 20.1:1 | 4.5:1 |
Caption and card labels | --luz-text-secondary on --luz-surface-page | 6.2:1 | 7.5:1 | 19.3:1 | 4.5:1 |
Focus ring of a sortable heading | --luz-focus-ring on --luz-surface-strong | 4.8:1 | 5.6:1 | 18.0:1 | 3:1 |
Focus ring | --luz-focus-ring on --luz-surface-page | 5.6:1 | 7.7:1 | 21.0:1 | 3:1 |
Measured from the token values of each mode, rounded down. Needs is the WCAG 2.2 AA minimum: 4.5:1 for text, 3:1 for focus rings, borders and icons.
- Focus: the scrolling wrapper takes it, with a 2 px
--luz-focus-ringoutline 2 px outside. So does the button of a sortable heading. - Not colour alone: a sortable heading is underlined, and the sorted one has a triangle, in the colour of its text, that points up or down. The direction is also in
aria-sortand in the status line. - Target: a heading button is at least 24 px high, whatever the padding of the cell.
- Edges: row lines (
--luz-divider) are decorative; the header has its own surface and the cells their headings.
Tokens it uses
The 17 tokens that components/table.css reads, by family. Each family links to its page, with the values in every mode. In Figma they are variables with the same names: see Tokens in Figma.
- Color
--luz-divider,--luz-focus-ring,--luz-surface-hover,--luz-surface-page,--luz-surface-strong,--luz-text-primary,--luz-text-secondary- Spacing and sizing
--luz-space-2xs,--luz-space-md,--luz-space-sm,--luz-space-xs- Typography
--luz-font-sans,--luz-text-md,--luz-text-sm- Radius
--luz-radius-md- Motion
--luz-duration-base,--luz-duration-fast
Keyboard and ARIA are in the Code tab.