Table
The class goes on a real <table>. It is <th> with scope that lets screen readers say “Amount, £1,240” when the user is in a cell. If you build the table from <div> elements, that connection does not exist, and the numbers have no meaning.
How to use it
Section titled “How to use it”| Invoice | Due | Amount |
|---|---|---|
| 2026-0481 | 4 March | £1,240 |
| 2026-0482 | 4 April | £980 |
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/table/table.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css --><table class="lt-table" data-variant="striped"><caption>Invoices in 2026</caption><thead> <tr> <th scope="col">Invoice</th> <th scope="col">Due</th> <th scope="col" data-align="end">Amount</th> </tr></thead><tbody> <tr> <th scope="row">2026-0481</th> <td>4 March</td> <td data-align="end">£1,240</td> </tr> <tr> <th scope="row">2026-0482</th> <td>4 April</td> <td data-align="end">£980</td> </tr></tbody></table>import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/table.css"lt is used in two ways, and which one depends on whether you create the element or find it.
If you create the markup, you spread the attributes in. That is the route in React, Astro and anything else that renders:
import { lt } from "@lo-tek/interfaces/react"
<div className={lt.table.scroll} tabIndex={0} role="region" aria-label="Invoices"> <table {...lt.table({ variant: "striped" })}> <caption>Invoices in 2026</caption> <thead><tr><th scope="col">Case</th></tr></thead> <tbody><tr><th scope="row">2024/1187</th></tr></tbody> </table></div>The import goes to /react here, since the block is JSX. From there the keys are called className and htmlFor. In Astro and anything else without React you import @lo-tek/interfaces and get class and for.
If the element is already there, you set them on it. That is the route when you enhance HTML a server or a template has sent:
import { lt } from "@lo-tek/interfaces"
const table = document.querySelector<HTMLTableElement>("#invoices")
lt.setAttributes(table, lt.table({ variant: "striped", hoverable: true }))lt.table.scroll gives the class for the container around it. See Type-safe use.
| Attribute | Effect |
|---|---|
data-variant="striped" | A muted surface on every other row |
data-hoverable | Highlights the row under the mouse. Use only when the row can be clicked. |
data-align="end" on a cell | Right-aligns, for amounts and quantities |
Numbers are set in tabular-nums, so the digits have equal width and the column can be read downwards.
Sortable columns
Section titled “Sortable columns”A column that can be sorted has a button in its header, and the direction it is sorted in is in aria-sort on the <th>. The arrow is drawn from the same attribute, so what the screen reader says and what the reader sees are the same. The button fills the cell, so the header has the same height and indent as the cells below. Without aria-sort the button shows both arrows, muted.
| Amount | ||
|---|---|---|
| 2026-0482 | 4 April | £980 |
| 2026-0481 | 4 March | £1,240 |
<table class="lt-table"><caption>Invoices in 2026<span class="lt-sr-only">, columns with a button can be sorted</span></caption><thead> <tr> <th scope="col"> <button type="button" class="lt-table__sort">Invoice</button> </th> <th scope="col" aria-sort="descending"> <button type="button" class="lt-table__sort">Due</button> </th> <th scope="col" data-align="end">Amount</th> </tr></thead><tbody> <tr> <th scope="row">2026-0482</th> <td>4 April</td> <td data-align="end">£980</td> </tr> <tr> <th scope="row">2026-0481</th> <td>4 March</td> <td data-align="end">£1,240</td> </tr></tbody></table>lt.tableSort() gives one attribute set for the cell and one for the button:
import { lt } from "@lo-tek/interfaces/react"
const due = lt.tableSort({ direction: "descending" })
<th scope="col" {...due.header}> <button {...due.button} onClick={() => sortBy("due")}>Due</button></th>Without direction the cell gets no aria-sort. lt.tableSort.isDirection() checks a value from the URL or the form, and tableSortDirections from the main entry point holds the two valid values.
The sorting itself is up to you. In React the app keeps the column and the direction in state and redraws the rows. If the server sends HTML, in Datastar or a template, it sorts and writes aria-sort on the right header. Without JavaScript the button can be a link, <a class="lt-table__sort" href="?sort=due&direction=ascending">, and the server sorts on the next page load. The link gets the class alone, since lt.tableSort().button also gives type="button".
If you redraw the table after a click, put focus back on the button the user pressed. Otherwise it ends up on <body>. List with filters does this in its demo.
Wide tables
Section titled “Wide tables”A table wider than the screen must be scrollable sideways, and that area must be reachable with the keyboard:
<div class="lt-table-scroll" tabindex="0" role="region" aria-label="Invoices"> <table class="lt-table">…</table></div>tabindex="0" is not optional. Without it, nobody navigating by keyboard reaches the content beyond the edge. role="region" with a text makes the screen reader say what the area contains when focus lands there.
Classes you can style
Section titled “Classes you can style”| Class | What it is |
|---|---|
.lt-table | The table itself. The variant comes from data-variant. |
.lt-table-scroll | The container that lets a wide table scroll |
.lt-table__sort | The button in a sortable column header. The arrow is ::after. |
| Variable | Default |
|---|---|
--lt-table-cell-padding | var(--lt-spacing-2) var(--lt-spacing-3) |
See Customising for how the variables and the layer work.
A denser table
Section titled “A denser table”If you need more rows on screen, it is the space in the cells that should go down. That is one variable, and there is no data-size for it:
| Invoice | Amount |
|---|---|
| 2026-0481 | £1,240 |
| 2026-0482 | £980 |
<table class="lt-table compact"><caption>Invoices in 2026</caption><thead> <tr> <th scope="col">Invoice</th> <th scope="col" data-align="end">Amount</th> </tr></thead><tbody> <tr> <th scope="row">2026-0481</th> <td data-align="end">£1,240</td> </tr> <tr> <th scope="row">2026-0482</th> <td data-align="end">£980</td> </tr></tbody></table>.compact {--lt-table-cell-padding: var(--lt-spacing-1) var(--lt-spacing-2);}Leave the font size alone. --lt-font-size-m in the table is the same as the body text, and numbers become harder to read down a column when they shrink. If you still want smaller text, it is an ordinary rule in your own CSS, not a variable, since it is not meant to be changed.
Accessibility
Section titled “Accessibility”- Every column header should be
<th scope="col">, and the row’s own key<th scope="row">. Then the screen reader knows which column and row a cell belongs to. - Give the table a
<caption>. It says what the table contains, and is the first thing announced. - Never merge cells to make a layout look right. Merged cells make navigation unpredictable.
- If rows should be clickable, put the link in a cell. A whole row that is clickable cannot be reached by keyboard.
aria-sortgoes on the<th>, not on the button, and only on the column the table is sorted by. Columns that are not sorted do not have the attribute.- Say in the
<caption>that the columns can be sorted, for example with.lt-sr-only. The screen reader reads the caption when the user reaches the table, and otherwise the buttons in the header are the only hint. - Screen readers announce a change in
aria-sortdifferently, and some not at all. If the change should be spoken, write it in an element withrole="status".