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.

Revenue by region
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.

Sorting in application code
// 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. Use visuallyHidden when 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 scrollLabel so 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 is aria-hiddenaria-sort already carries the direction.

Props

Table.Root

PropTypeDefault
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.booleanfalse
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.booleanfalse
class Extra classes merged onto the `<table>`.string
children Caption, Header, Body and Footer.Snippet

Table.Head — a column header.

PropTypeDefault
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.booleanfalse
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.booleanfalse
class Extra classes merged onto the `<th>`.string
children Snippet

Table.Cell

PropTypeDefault
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.booleanfalse
class Extra classes merged onto the `<td>`.string
children Snippet

Table.Row

PropTypeDefault
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.booleanfalse
class Extra classes merged onto the `<tr>`.string
children Snippet

Table.Caption

PropTypeDefault
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.booleanfalse
class Extra classes merged onto the caption.string
children Snippet

Header, Body, Footer and RowHeader each take class plus their native attributes.