Skip to content

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
Sales by city
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.

Sales by city
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.

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.

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:

Sales by city
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.

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 spend
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.

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
Sales by city
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
<tr
aria-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.

Sales by city
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>
  • zebra washes 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.
  • maxHeight is 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.
  • pinFirst freezes 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.
Sales by city
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>

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.

Small, plain
City Orders Total
Quito 128 $24,310
Guayaquil 96 $18,740
Cuenca 74 $12,980
Large, boxed
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>

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.

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.

  • The caption names two things. The <table> gets it as its accessible name, and the wrapper region borrows it through aria-labelledby. One string, one useId(), 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 rule scrollable-region-focusable, which a bare overflow: auto fails.
  • aria-sort is 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. Keep scope="col" on every head cell while you are there.
  • Selection needs more than a colour. aria-selected on 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 outline at 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 in table.css.
  • Contrast. Body text is base-content on base-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 -content on top, which the colour contract holds at 4.5:1 or better.
  • Forced colors. A boxed table borders in CanvasText; a selected row takes Highlight / HighlightText with forced-color-adjust: none. The sort chevron is currentColor borders, so it survives untouched.
  • Reduced motion. The two transitions — the sort label’s colour and the row wash — go to 0s through one variable.

Do

Sorted with aria-sort
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

Sorted with a click handler
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

Numbers aligned to the end
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

Numbers left as text
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.