Skip to main content

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
ServiceStatusCPURegion
api-gatewayRunning12%eu-west-1
data-pipelineDegraded78%us-east-1
mailerStopped0%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 colspan heading 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.

HTML
<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:

HTML
<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 attributeEffect
.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--sticky-col
Keeps the first column in view while the table scrolls sideways
.luz-table--compact
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__numeric 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.

Shipments this week
RouteParcelsWeight (kg)
Sevilla to Madrid1,2048,430.5
Cádiz to Málaga3121,912.0
Huelva to Faro58406.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.

Parcels per route
RouteParcelsWeight (kg)Notes
Cádiz to Málaga3121,912.0Daily
Huelva to Faro58406.3Monday and Thursday
Sevilla to Madrid1,2048,430.5Daily
HTML
<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, with Room 2 before Room 10.
  • A number with a thousands separator, a unit or a currency needs data-sort-value with the plain number (1204 for 1,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-sort and 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__numeric sets text-align: end and font-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--compact changes 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"> carries aria-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 its href with the table's id (?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-sort on the headings. luz.js loads table-sort.js on 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, writes aria-sort on 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 display of the table elements, which can hide the table from some screen readers. The stylesheet recommends role="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.

JS
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 an aria-label, so that focus stop has a name.
  • scope="col" on column headers, and scope="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-sort is on the one sorted <th scope="col">. Each sort is announced by the status line (<output>, role status), in the language of the table; the strings are sortedAsc and sortedDesc of core's strings (see JavaScript modules; data-luz-label-sorted-asc and data-luz-label-sorted-desc override 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

Do The unit in the heading, the same decimals, and the column aligned to the end, heading included.
Don't A vague heading, the unit in every cell, mixed decimals and no alignment: the column is hard to compare.

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.

PartColour on backgroundLightDarkHigh contrastNeeds
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-ring outline 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-sort and 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.

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.