Table
Styled data table with sortable headers.
Usage
Custom rather than a Bits UI wrapper — Bits ships no table, and there is nothing headless to
buy. A data table is already a solved accessibility problem in HTML: <table>, <caption> and scope on the headers. What this adds is getting that markup right by default,
plus the styling and the scroll container on top.
| Region | |
|---|---|
| Argentina | 1,200 |
| Brazil | 3,400 |
| Uruguay | 340 |
| Total | 4,940 |
Click Revenue three times: ascending, descending, then back to the original order.
Sorting is yours
This component does not sort your data. Head sortable renders the
button and sets aria-sort; you apply the order.
That is not laziness. A component cannot know whether the rows are even on this machine, how
sorting interacts with your pagination, or which locale the text should be compared in — and 'á' < 'b' is true in Spanish and false in a plain codepoint sort. A built-in
sort would be wrong in all three ways and hard to override in each.
// The component renders the button and sets aria-sort.
// Applying the order is yours, because only you know:
// - whether the rows even live on this machine
// - how it interacts with pagination
// - the user's locale, which decides how text compares
const collator = new Intl.Collator(locale, { numeric: true, sensitivity: 'base' });
const sorted = $derived(
sort === 'none'
? rows
: [...rows].sort((a, b) => {
const n = collator.compare(a.region, b.region);
return sort === 'asc' ? n : -n;
})
);The cycle is none → ascending → descending → none. The third state exists on purpose: the order rows arrived in is often meaningful, and a two-state toggle throws it away permanently.
Keep aria-sort on one column, and only when you have actually
applied it. Announcing a sort you did not apply is a lie assistive technology has no way to check.
Why not role="grid"
Root is a plain <table> with no role
override. role="grid" promises a full keyboard interaction model — arrow
keys moving a focus cursor between cells, Home, End, Ctrl+Home — and a screen reader switches into
that mode as soon as it sees the role.
So claiming it without implementing it is worse than not claiming it: the user is put into a navigation mode where the keys do nothing. If you need a spreadsheet, you need a real grid implementation, not this component with a role attribute.
The scroll container
Root renders the horizontal scroll container itself, because a wide table
that scrolls the whole page instead of itself is the single most common table defect.
Pass scrollLabel. A scroll container that is not focusable cannot be
scrolled without a pointer — the columns past the right edge are simply unreachable by
keyboard. Focusability needs a name, though, so tabindex is only added when
you provide one: an unnamed focusable region is announced as nothing at all.
scrollLabel is not the table's name — that is what Caption is for.
stickyHeader caps the container with --sve-table-max-height (24rem by default). Without a bounded height there
is nothing for the header to stick inside and the page scrolls instead.
Selected rows
Row selected styles the row and sets data-selected. It does not set aria-selected, which is only valid inside a grid, listbox or treegrid — on a plain <tr> it is ignored,
and validators report it as an error.
Selection also has to be operable, so put a real Checkbox in
the first cell and let selected follow its state. Styling alone tells sighted
users and nobody else.
Numeric columns
Pass numeric to both the Head and its Cells. Numbers are compared by reading down the column, which only
works when the digits line up: right alignment aligns the units place, and tabular-nums stops a proportional font from giving 1 less width than 8.
Format the number yourself — Intl.NumberFormat with the user's locale. Thousands
separators and decimal marks are not universal, and a table is exactly where that shows.
Accessibility
- Give it a
Caption. A table announced as just "table" is useless on a page with more than one. It must be the first child of the table. UsevisuallyHiddenwhen a heading above already says it — that hides the picture, not the name. - Give the identifying cell of each row a
RowHeader. That is what turns "4.2%" into "Bounce rate, Argentina, 4.2%" — without it the value has a column but no subject. - Put totals in
Footer, not as a last body row. It is not data, and a screen reader user should be told when the rows stop. - Pass
scrollLabelso the overflow is reachable by keyboard. - Sortable headers are real
<button>s inside the cell, because a clickable<th>is neither focusable nor operable by keyboard. The arrow glyph isaria-hidden—aria-sortalready carries the direction.
Props
Table.Root
| Prop | Type | Default |
|---|---|---|
scrollLabel Names the horizontal scroll region so a keyboard user can reach it. A table wider than its container scrolls, and a scroll container that is not focusable cannot be scrolled without a pointer — the columns off the right edge are simply unreachable. Focusability requires a name, though: an unnamed focusable region is announced as nothing at all, which is why `tabindex` is only added when this is set. It is NOT the table's accessible name. Use `Table.Caption` for that. | string | — |
density Row height. Compact fits more on screen; comfortable is easier to track across a wide row. | 'compact' | 'default' | 'comfortable' | 'default' |
zebra Shade alternating body rows. | boolean | false |
stickyHeader Pin the header while the body scrolls vertically. Needs a height on the scroll container — set `--sve-table-max-height`, or the page scrolls instead of the table and there is nothing for the header to stick to. | boolean | false |
class Extra classes merged onto the `<table>`. | string | — |
children Caption, Header, Body and Footer. | Snippet | — |
Table.Head — a column header.
| Prop | Type | Default |
|---|---|---|
sortable Render the label as a button and expose `aria-sort`. A clickable `<th>` is not focusable and not operable by keyboard, so the control has to be a real `<button>` inside the cell. | boolean | false |
sort Current sort of this column. Only ONE column should be anything other than `'none'` unless you genuinely sort by several at once — `aria-sort` on two columns claims a sort order the data does not have. | 'none' | 'asc' | 'desc' | 'none' |
onSortChange Called with the next direction when the header is activated, cycling none → asc → desc → none. This component does NOT sort your data. Sorting is application logic: it may be server-side, it interacts with pagination, and comparing text correctly needs an `Intl.Collator` for the user's locale. Announcing a sort you did not apply is a lie to assistive technology. | (direction: SortDirection) => void | — |
numeric Right-align and use tabular figures, for a column of numbers. | boolean | false |
class Extra classes merged onto the `<th>`. | string | — |
children | Snippet | — |
Table.Cell
| Prop | Type | Default |
|---|---|---|
numeric Right-align and use tabular figures. Numbers are compared by reading down the column, which only works when the digits line up: right alignment aligns the units place, and `tabular-nums` stops a proportional font from giving `1` less width than `8`. Apply it to the matching `Table.Head` too. | boolean | false |
class Extra classes merged onto the `<td>`. | string | — |
children | Snippet | — |
Table.Row
| Prop | Type | Default |
|---|---|---|
selected Mark the row as selected. This styles the row and sets `data-selected`; it does NOT set `aria-selected`, which is only valid inside a `grid`, `listbox` or `treegrid` and is ignored — or reported as an error — on a plain `<tr>`. Selection has to be operable, so put a real `Checkbox` in the first cell and let this follow its state. Styling alone tells sighted users and nobody else. | boolean | false |
class Extra classes merged onto the `<tr>`. | string | — |
children | Snippet | — |
Table.Caption
| Prop | Type | Default |
|---|---|---|
visuallyHidden Keep the caption available to assistive technology but hide it visually. Use it when the heading above the table already says the same thing — removing the caption entirely would leave the table unnamed. | boolean | false |
class Extra classes merged onto the caption. | string | — |
children | Snippet | — |
Header, Body, Footer and RowHeader each take class plus their native attributes.