Table
A semantic table with a skin, inside a wrapper that scrolls. It ships no data model at all: no columns array, no rows array, no sort state, no selection state, no pagination. Sorting is aria-sort on a head cell plus a real button you wire; selection is aria-selected on a row; alignment is one attribute on a cell. The component renders the wrapper, the table and the hidden caption — everything between thead and tfoot is your markup, which is why the same stylesheet fits a server-rendered page with no JavaScript in it.
- Stable
- React 19
- Zero JS
- Markup contract
- Named scroll region
| City | Orders | Total |
|---|---|---|
| Quito | 128 | $24,310 |
| Guayaquil | 96 | $18,740 |
| Cuenca | 74 | $12,980 |
| Ambato | 51 | $8,420 |
| Loja | 38 | $6,115 |
A boxed table with a zebra wash. The caption above it exists — it names the table and the scroll region, and it is visually hidden.
| City | Orders | Total |
|---|---|---|
| Quito | 128 | $24,310 |
| Guayaquil | 96 | $18,740 |
| Cuenca | 74 | $12,980 |
| Ambato | 51 | $8,420 |
| Loja | 38 | $6,115 |
The wrapper, the table and the hidden caption are the component. The three sections are yours.
Code
<Table caption="Sales by city"><thead> <tr> <th scope="col"><span className="wayra-table-head">City</span></th> <th scope="col" data-align="end"><span className="wayra-table-head">Orders</span></th> <th scope="col" data-align="end"><span className="wayra-table-head">Total</span></th> </tr></thead><tbody> {rows.map((row) => ( <tr key={row.city}> <td>{row.city}</td> <td data-align="end">{row.orders}</td> <td data-align="end">{money(row.total)}</td> </tr> ))}</tbody></Table><div class="wayra-table" role="region" aria-labelledby="usage-caption" tabindex="0" data-variant="boxed" data-color="primary" data-size="md"> <table> <caption id="usage-caption" class="wayra-table-caption">Sales by city</caption> <thead> <tr> <th scope="col"> <span class="wayra-table-head">City</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Orders</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Total</span> </th> </tr> </thead> <tbody> <tr> <td>Quito</td> <td data-align="end">128</td> <td data-align="end">$24,310</td> </tr> <tr> <td>Guayaquil</td> <td data-align="end">96</td> <td data-align="end">$18,740</td> </tr> <tr> <td>Cuenca</td> <td data-align="end">74</td> <td data-align="end">$12,980</td> </tr> <tr> <td>Ambato</td> <td data-align="end">51</td> <td data-align="end">$8,420</td> </tr> <tr> <td>Loja</td> <td data-align="end">38</td> <td data-align="end">$6,115</td> </tr> </tbody> </table></div>caption is required. A table with no accessible name is announced as “table” and
nothing else, and here it names two things: the <table> and the scroll region
around it. It renders as a real, visually hidden <caption> — pass yours through
the prop rather than writing one of your own, since the component’s is always the
first child of the table.
Two kinds of head cell
Section titled “Two kinds of head cell”A head cell — a <th> in <thead> — carries padding: 0 on purpose: whatever
sits inside it holds the padding, so a sort button fills its whole cell instead
of leaving a dead margin around it. That means every head cell needs one of
these two inside it, or it collapses:
<!-- A column that never sorts --><th scope="col"><span class="wayra-table-head">City</span></th>
<!-- A column that does --><th scope="col" aria-sort="ascending"> <button type="button" class="wayra-table-sort">City</button></th>The two share one geometry — same padding, same minimum height, same baseline — so a mixed header row still lines up. Only the button is a target, takes the shared focus ring, and grows a chevron.
A <th scope="row"> in <tbody> or <tfoot> is a row header, not a head cell.
It is drawn as a body cell of its row — the cell padding, the row rule, the body
colour, medium weight in <tbody> and the footer’s in <tfoot> — and it
scrolls, fills, pins and sticks with that row. It needs nothing inside it.
Sorting is yours to drive
Section titled “Sorting is yours to drive”The stylesheet keys on aria-sort alone. Put it on the <th> of the column that
is currently sorted, leave it off every other one, and re-sort the rows yourself:
| Quito | 128 | $24,310 |
| Guayaquil | 96 | $18,740 |
| Cuenca | 74 | $12,980 |
| Ambato | 51 | $8,420 |
| Loja | 38 | $6,115 |
Total is sorted descending. The other two buttons are live and unmarked.
Code
const [sort, setSort] = useState({ key: 'total', direction: 'descending' });
const rows = [...data].sort((a, b) =>sort.direction === 'ascending' ? compare(a, b, sort.key) : compare(b, a, sort.key),);
<Table caption="Sales by city" interactive><thead> <tr> {COLUMNS.map((column) => ( <th key={column.key} scope="col" aria-sort={sort.key === column.key ? sort.direction : undefined} data-align={column.numeric ? 'end' : undefined} > <button type="button" className="wayra-table-sort" onClick={() => toggle(column.key)}> {column.label} </button> </th> ))} </tr></thead>…</Table><div class="wayra-table" role="region" aria-labelledby="sorted-caption" tabindex="0" data-variant="boxed" data-color="primary" data-size="md" data-interactive="true"> <table> <caption id="sorted-caption" class="wayra-table-caption">Sales by city</caption> <thead> <tr> <th scope="col"> <button type="button" class="wayra-table-sort">City</button> </th> <th scope="col" data-align="end"> <button type="button" class="wayra-table-sort">Orders</button> </th> <th scope="col" data-align="end" aria-sort="descending"> <button type="button" class="wayra-table-sort">Total</button> </th> </tr> </thead> <tbody> <tr> <td>Quito</td> <td data-align="end">128</td> <td data-align="end">$24,310</td> </tr> <tr> <td>Guayaquil</td> <td data-align="end">96</td> <td data-align="end">$18,740</td> </tr> <tr> <td>Cuenca</td> <td data-align="end">74</td> <td data-align="end">$12,980</td> </tr> <tr> <td>Ambato</td> <td data-align="end">51</td> <td data-align="end">$8,420</td> </tr> <tr> <td>Loja</td> <td data-align="end">38</td> <td data-align="end">$6,115</td> </tr> </tbody> </table></div>The mark is two borders on an empty box, not a glyph — Windows high contrast
strips backgrounds but keeps borders, and a border needs no font. It is invisible
at rest, appears when the cell gains aria-sort, and turns from down to up when
the value is ascending; in a mirrored direction the two borders meet at the
opposite corner, so the direction flips the sign of both angles. It reads a
dir="rtl" on the <table> or any ancestor, which is what the browser floor
understands; :dir() (Chrome 120) only puts an LTR island back.
There is no onSort, no sortable prop and no default comparator. Sorting means
changing the order of your rows, and only you know whether that is a
localeCompare, a numeric subtraction, a fetch or a URL change.
Alignment, numerals and truncation
Section titled “Alignment, numerals and truncation”data-align="end" is the only cell attribute the component documents. It goes on
the <td> and on its <th>, right-aligns both, and switches them to tabular
figures so a column of numbers lines up on the decimal.
Cells truncate with an ellipsis at --wayra-table-cell-max, which is 24ch.
Override it on the wrapper for every column, or on one <td> and its <th> for
a single one:
| Campaign | Owner | Spend |
|---|---|---|
| Summer relaunch across the three coastal regions | A. Tijoux | $24,310 |
| Loyalty pilot | L. Bunker | $8,420 |
The first cell is longer than 18ch, so it ends in an ellipsis instead of stretching the column.
Code
<Table caption="Campaign spend" style={{ '--wayra-table-cell-max': '18ch' }}><div class="wayra-table" role="region" aria-labelledby="truncating-caption" tabindex="0" data-variant="boxed" data-color="primary" data-size="md" style="--wayra-table-cell-max: 18ch"> <table> <caption id="truncating-caption" class="wayra-table-caption">Campaign spend</caption> <thead> <tr> <th scope="col"> <span class="wayra-table-head">Campaign</span> </th> <th scope="col"> <span class="wayra-table-head">Owner</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Spend</span> </th> </tr> </thead> <tbody> <tr> <td>Summer relaunch across the three coastal regions</td> <td>A. Tijoux</td> <td data-align="end">$24,310</td> </tr> <tr> <td>Loyalty pilot</td> <td>L. Bunker</td> <td data-align="end">$8,420</td> </tr> </tbody> </table></div>A style you pass the component lands on the <table>, not the wrapper, so
either place works: the example above writes the property on the wrapper by hand,
which hand-authored markup is free to do.
Selection is a look
Section titled “Selection is a look”aria-selected="true" on a <tr> paints the whole row in the table’s color
role. That is where the library stops:
| The library draws | You write |
|---|---|
tr[aria-selected="true"] cells, a row header and the pinned cell included — the accent fill and its -content text |
The attribute, and the state behind it |
data-interactive — a hover wash on body rows, under @media (hover: hover) |
The click handler |
tbody tr:focus-visible — an inward focus outline |
tabindex, and where it moves |
| Nothing | role="grid", aria-multiselectable, arrow-key navigation |
| City | Orders | Total |
|---|---|---|
| Quito | 128 | $24,310 |
| Guayaquil | 96 | $18,740 |
| Cuenca | 74 | $12,980 |
| Ambato | 51 | $8,420 |
| Loja | 38 | $6,115 |
Every ARIA attribute on those rows is the consumer's. The library contributed the fill.
Code
<traria-selected={selected === row.city}tabIndex={0}onClick={() => setSelected(row.city)}onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setSelected(row.city); }}}><div class="wayra-table" role="region" aria-labelledby="selected-caption" tabindex="0" data-variant="boxed" data-color="primary" data-size="md" data-interactive="true"> <table> <caption id="selected-caption" class="wayra-table-caption">Sales by city</caption> <thead> <tr> <th scope="col"> <span class="wayra-table-head">City</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Orders</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Total</span> </th> </tr> </thead> <tbody> <tr aria-selected="false" tabindex="-1"> <td>Quito</td> <td data-align="end">128</td> <td data-align="end">$24,310</td> </tr> <tr aria-selected="false" tabindex="-1"> <td>Guayaquil</td> <td data-align="end">96</td> <td data-align="end">$18,740</td> </tr> <tr aria-selected="true" tabindex="0"> <td>Cuenca</td> <td data-align="end">74</td> <td data-align="end">$12,980</td> </tr> <tr aria-selected="false" tabindex="-1"> <td>Ambato</td> <td data-align="end">51</td> <td data-align="end">$8,420</td> </tr> <tr aria-selected="false" tabindex="-1"> <td>Loja</td> <td data-align="end">38</td> <td data-align="end">$6,115</td> </tr> </tbody> </table></div>Row selection carries wider ARIA duties than one attribute — a selectable grid is
role="grid", says whether it is multi-selectable, and moves a single tab stop
with the arrow keys. Those stay yours: the library styles the state, it does not
own the widget.
Zebra, pinned columns, sticky ends
Section titled “Zebra, pinned columns, sticky ends”| City | Orders | Total |
|---|---|---|
| Quito | 128 | $24,310 |
| Guayaquil | 96 | $18,740 |
| Cuenca | 74 | $12,980 |
| Ambato | 51 | $8,420 |
| Loja | 38 | $6,115 |
| Total | 387 | $70,565 |
Scroll inside the box: the header row and the totals row stay put.
Code
<Table caption="Sales by city" zebra interactive maxHeight="13rem">…<tfoot> <tr> <td>Total</td> <td data-align="end">387</td> <td data-align="end">$70,565</td> </tr></tfoot></Table><div class="wayra-table" role="region" aria-labelledby="zebra-caption" tabindex="0" data-variant="boxed" data-color="primary" data-size="md" data-zebra="true" data-interactive="true" style="--wayra-table-max-height: 13rem"> <table> <caption id="zebra-caption" class="wayra-table-caption">Sales by city</caption> <thead> <tr> <th scope="col"> <span class="wayra-table-head">City</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Orders</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Total</span> </th> </tr> </thead> <tbody> <tr> <td>Quito</td> <td data-align="end">128</td> <td data-align="end">$24,310</td> </tr> <tr> <td>Guayaquil</td> <td data-align="end">96</td> <td data-align="end">$18,740</td> </tr> <tr> <td>Cuenca</td> <td data-align="end">74</td> <td data-align="end">$12,980</td> </tr> <tr> <td>Ambato</td> <td data-align="end">51</td> <td data-align="end">$8,420</td> </tr> <tr> <td>Loja</td> <td data-align="end">38</td> <td data-align="end">$6,115</td> </tr> </tbody> <tfoot> <tr> <td>Total</td> <td data-align="end">387</td> <td data-align="end">$70,565</td> </tr> </tfoot> </table></div>zebrawashes every second body row and takes the per-row hairlines away with it. The two are never on together — a ruled zebra reads as noise at these row heights.maxHeightis any CSS length. It caps the wrapper, which is what makes the wrapper scroll, which is what the sticky<thead>cells and the sticky<tfoot>stick to. Without it nothing sticks, because nothing scrolls.pinFirstfreezes the first cell of every row while the rest scrolls sideways. The pinned cell paints opaque, so the columns pass behind it rather than through it, and it paints its row’s fill: the surface, or the role solid when the row is selected.
| City | Jan | Feb | Mar | Apr | May | Jun |
|---|---|---|---|---|---|---|
| Quito | $24,310 | $24,310 | $24,310 | $24,310 | $24,310 | $24,310 |
| Guayaquil | $18,740 | $18,740 | $18,740 | $18,740 | $18,740 | $18,740 |
| Cuenca | $12,980 | $12,980 | $12,980 | $12,980 | $12,980 | $12,980 |
| Ambato | $8,420 | $8,420 | $8,420 | $8,420 | $8,420 | $8,420 |
Drag or shift-scroll sideways: the city column stays.
Code
<Table caption="Sales by city" pinFirst><div class="wayra-table" role="region" aria-labelledby="pinned-caption" tabindex="0" data-variant="boxed" data-color="primary" data-size="md" data-pin-first="true" style="max-inline-size: 26rem"> <table> <caption id="pinned-caption" class="wayra-table-caption">Sales by city</caption> <thead> <tr> <th scope="col"> <span class="wayra-table-head">City</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Jan</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Feb</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Mar</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Apr</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">May</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Jun</span> </th> </tr> </thead> <tbody> <tr> <td>Quito</td> <td data-align="end">$24,310</td> <td data-align="end">$24,310</td> <td data-align="end">$24,310</td> <td data-align="end">$24,310</td> <td data-align="end">$24,310</td> <td data-align="end">$24,310</td> </tr> <tr> <td>Guayaquil</td> <td data-align="end">$18,740</td> <td data-align="end">$18,740</td> <td data-align="end">$18,740</td> <td data-align="end">$18,740</td> <td data-align="end">$18,740</td> <td data-align="end">$18,740</td> </tr> <tr> <td>Cuenca</td> <td data-align="end">$12,980</td> <td data-align="end">$12,980</td> <td data-align="end">$12,980</td> <td data-align="end">$12,980</td> <td data-align="end">$12,980</td> <td data-align="end">$12,980</td> </tr> <tr> <td>Ambato</td> <td data-align="end">$8,420</td> <td data-align="end">$8,420</td> <td data-align="end">$8,420</td> <td data-align="end">$8,420</td> <td data-align="end">$8,420</td> <td data-align="end">$8,420</td> </tr> </tbody> </table></div>The wrapper is the interesting part
Section titled “The wrapper is the interesting part”Everything above hangs off one decision: the styled root is not the <table>, it
is a <div> around it, and that <div> is a scroll container.
A scroll container that no one can focus is an accessibility failure — a keyboard user cannot scroll it. So the wrapper is a named, focusable region, and all three attributes matter:
<div class="wayra-table" role="region" aria-labelledby="sales-caption" tabindex="0" data-variant="boxed" data-color="primary" data-size="md"> <table> <caption id="sales-caption" class="wayra-table-caption">Sales by city</caption>role="region" makes it a landmark, aria-labelledby points at the same hidden
caption that names the table, and tabindex="0" puts it in the tab order — which
is why the first Tab press on any example here lands on the box itself, and the
second reaches the first sort button. Drop any one of the three and axe reports
scrollable-region-focusable.
Two more things the wrapper decides. The table sets border-collapse: separate
rather than collapse, because a collapsed border is drawn between cells and
would show through the sticky header row; every rule on this page is an
inset 0 -1px 0 shadow on a cell instead. And the stickiness runs on a
three-step ladder: a body cell in a pinned column is z-index: 1, a header cell
is 2, and the header cell of the pinned column is 3 — it has to clear both
its own row and its own column.
| City | Orders | Total |
|---|---|---|
| Quito | 128 | $24,310 |
| Guayaquil | 96 | $18,740 |
| Cuenca | 74 | $12,980 |
| City | Orders | Total |
|---|---|---|
| Quito | 128 | $24,310 |
| Guayaquil | 96 | $18,740 |
| Cuenca | 74 | $12,980 |
plain drops the box and the corner, for a table that already sits inside one. An md row is the height of an md Input.
Code
<Table variant="plain" size="sm" … /><Table size="lg" … /><div class="wayra-table" role="region" aria-labelledby="plain-caption" tabindex="0" data-variant="plain" data-color="primary" data-size="sm"> <table> <caption id="plain-caption" class="wayra-table-caption">Small, plain</caption> <thead> <tr> <th scope="col"> <span class="wayra-table-head">City</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Orders</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Total</span> </th> </tr> </thead> <tbody> <tr> <td>Quito</td> <td data-align="end">128</td> <td data-align="end">$24,310</td> </tr> <tr> <td>Guayaquil</td> <td data-align="end">96</td> <td data-align="end">$18,740</td> </tr> <tr> <td>Cuenca</td> <td data-align="end">74</td> <td data-align="end">$12,980</td> </tr> </tbody> </table></div><div class="wayra-table" role="region" aria-labelledby="large-caption" tabindex="0" data-variant="boxed" data-color="primary" data-size="lg"> <table> <caption id="large-caption" class="wayra-table-caption">Large, boxed</caption> <thead> <tr> <th scope="col"> <span class="wayra-table-head">City</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Orders</span> </th> <th scope="col" data-align="end"> <span class="wayra-table-head">Total</span> </th> </tr> </thead> <tbody> <tr> <td>Quito</td> <td data-align="end">128</td> <td data-align="end">$24,310</td> </tr> <tr> <td>Guayaquil</td> <td data-align="end">96</td> <td data-align="end">$18,740</td> </tr> <tr> <td>Cuenca</td> <td data-align="end">74</td> <td data-align="end">$12,980</td> </tr> </tbody> </table></div>Styling contract
Section titled “Styling contract”The class plus the data-* attributes are public API — every preview on this
page is hand-authored HTML with no React in the page. The skin is written against
bare table, th and td inside the classed wrapper, so your markup only has to
carry the wrapper’s attributes and the four part names: wayra-table-caption,
wayra-table-head, wayra-table-sort, and the data-align on a cell.
| Property | Default | What it does |
|---|---|---|
--wayra-table-size |
calc(var(--wayra-size-field) * 10) at md |
Row height, and the step padding and type are fractions of. |
--wayra-table-pad |
calc(var(--wayra-table-size) * 0.34) |
Inline padding on every cell and head cell. It moves no type: the text size follows --wayra-table-size. |
--wayra-table-cell-max |
24ch |
Where a cell truncates instead of growing. |
--wayra-table-line |
base-content at 10% |
Every rule, the box border and the pinned divider. |
--wayra-table-max-height |
none |
Written by maxHeight; caps the wrapper so it scrolls. |
API reference
Section titled “API reference”TableProps extends ComponentProps<"table"> minus color and ref — both are
re-used below. The rest spreads onto the <table>.
| Prop | Type | Default | Description |
|---|---|---|---|
caption |
ReactNode |
— | The accessible name, as a visually hidden <caption>. Required. |
variant |
"boxed" | "plain" |
"boxed" |
boxed is the resting surface; plain drops the box and the radius. |
color |
WayraColor |
"primary" |
The selected row and the sort accent. Emitted as data-color. |
size |
"sm" | "md" | "lg" |
"md" |
Row height step. An md row is the height of an md Input. |
zebra |
boolean |
false |
Washes every second body row and drops the per-row rules. |
pinFirst |
boolean |
false |
Freezes the first cell of every row during a sideways scroll. |
interactive |
boolean |
false |
Hover wash on body rows. Changes no semantics. |
maxHeight |
string |
— | Any CSS length. Caps the wrapper, which is what makes both ends stick. |
ref |
Ref<HTMLDivElement> |
— | Lands on the wrapper — the scroll container, not the table. |
children |
ReactNode |
— | thead, tbody, tfoot, rendered verbatim after the caption. |
unstyled |
boolean |
false |
Drops the wayra-table classes, keeps every attribute. |
Under unstyled the caption loses its clip along with its class and becomes the
table’s visible title.
Accessibility
Section titled “Accessibility”- The caption names two things. The
<table>gets it as its accessible name, and the wrapper region borrows it througharia-labelledby. One string, oneuseId(), no duplicated label. - The scroll box is reachable.
role="region"+tabindex="0"is what lets a keyboard user focus the container and scroll it with the arrow keys. It is also the axe rulescrollable-region-focusable, which a bareoverflow: autofails. aria-sortis the announcement, and the chevron is a pseudo-element that is not in the accessibility tree at all. Exactly one column carries the attribute at a time. Keepscope="col"on every head cell while you are there.- Selection needs more than a colour.
aria-selectedon a row is a state, not a widget: pair it with a role that accepts it, a tab stop and keys, or the fill is decoration promising something that is not there. - Focus rings inward. The wrapper and a focusable row take an
outlineat a negative offset rather than the shared halo, which would sit on the neighbouring row or spill out of the scroll box. The sort button does take the shared halo, and a disabled one the shared fade — neither is drawn intable.css. - Contrast. Body text is
base-contentonbase-100: 17.9:1 light, 17.7:1 dark. Head cells and resting sort labels take the shared muted colour: 5.64:1 and 6.15:1. A selected row is the role solid with its-contenton top, which the colour contract holds at 4.5:1 or better. - Forced colors. A boxed table borders in
CanvasText; a selected row takesHighlight/HighlightTextwithforced-color-adjust: none. The sort chevron iscurrentColorborders, so it survives untouched. - Reduced motion. The two transitions — the sort label’s colour and the row
wash — go to
0sthrough one variable.
Do and don’t
Section titled “Do and don’t”Do
| Quito | 128 | $24,310 |
| Guayaquil | 96 | $18,740 |
| Cuenca | 74 | $12,980 |
A real button in the cell and aria-sort on the th. It is focusable, it announces the direction, and the chevron follows the attribute.
Don't
| City | Orders | Total |
|---|---|---|
| Quito | 128 | $24,310 |
| Guayaquil | 96 | $18,740 |
| Cuenca | 74 | $12,980 |
A click handler on a bare th: no tab stop, no announced direction, and no mark — the chevron is drawn from aria-sort. The header has lost its padding too, because a head th carries none of its own.
Do
| City | Orders | Total |
|---|---|---|
| Quito | 128 | $24,310 |
| Guayaquil | 96 | $18,740 |
| Cuenca | 74 | $12,980 |
data-align="end" on the numeric td and its th: right-aligned, and tabular figures so the digits sit on one width.
Don't
| City | Orders | Total |
|---|---|---|
| Quito | 128 | $24,310 |
| Guayaquil | 96 | $18,740 |
| Cuenca | 74 | $12,980 |
The same figures set as prose. Proportional digits and a leading edge, so no two totals line up and the column cannot be scanned.