Table
.table styles a real <table>. It stays a table at
every width — the markup is never rewritten, the header row is never turned into
<div>s — because a table's value is that values in a column can
be compared, and that only survives if the columns survive.
When to use it
Use a table when the reader needs to compare values down a column: amounts,
dates, counts, statuses. If they only ever read one row at a time, a
.list is easier to scan and much easier on a
phone.
.table-wrap is not optional. It carries the border, the radius, the
background and overflow-x: auto. Without it a wide table pushes
the whole page sideways, which is the single most common table bug there is.
| Invoice | Client | Due | Amount | Status |
|---|---|---|---|---|
| INV-2291 | Ada Chen | 3 Mar | 1,240.00 | Paid |
| INV-2292 | Marco Silva | 7 Mar | 86.50 | Pending |
| INV-2293 | Priya Raman | 11 Mar | 12,900.00 | Overdue |
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col">Client</th>
<th scope="col">Due</th>
<th scope="col" class="num">Amount</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-2291</th>
<td>Ada Chen</td>
<td>3 Mar</td>
<td class="num">1,240.00</td>
<td><span class="badge badge-good">Paid</span></td>
</tr>
<tr>
<th scope="row">INV-2292</th>
<td>Marco Silva</td>
<td>7 Mar</td>
<td class="num">86.50</td>
<td><span class="badge badge-warn">Pending</span></td>
</tr>
<tr>
<th scope="row">INV-2293</th>
<td>Priya Raman</td>
<td>11 Mar</td>
<td class="num">12,900.00</td>
<td><span class="badge badge-bad">Overdue</span></td>
</tr>
</tbody>
</table>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col">Client</th>
<th scope="col">Due</th>
<th scope="col" class="num">Amount</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-2291</th>
<td>Ada Chen</td>
<td>3 Mar</td>
<td class="num">1,240.00</td>
<td><span class="badge badge-good">Paid</span></td>
</tr>
<tr>
<th scope="row">INV-2292</th>
<td>Marco Silva</td>
<td>7 Mar</td>
<td class="num">86.50</td>
<td><span class="badge badge-warn">Pending</span></td>
</tr>
<tr>
<th scope="row">INV-2293</th>
<td>Priya Raman</td>
<td>11 Mar</td>
<td class="num">12,900.00</td>
<td><span class="badge badge-bad">Overdue</span></td>
</tr>
</tbody>
</table>
</div>
The sticky header
.table thead th is position: sticky with
inset-block-start: 0 and z-index: 1. In a tall table
inside a scrolling page the header stays visible, so a reader forty rows down still
knows which column is which. It needs no JavaScript and no fixed height.
It sticks to the nearest scrolling ancestor. Inside a
.table-wrap that only scrolls horizontally, that is the page — which is
what you want. If you give the wrapper a max-block-size and vertical
overflow, the header sticks to the wrapper instead, which is also what you want.
Numeric columns
.num on a cell sets text-align: end and
font-variant-numeric: tabular-nums. Tabular figures give every digit
the same advance width, so 1,240.00 and 12,900.00 line up
on the decimal point without any padding tricks. Put it on the
<th> as well as the cells, or the header sits over the wrong edge.
text-align: end, not right. Under
dir="rtl" the numbers move to the left edge along with the rest of the
table, which is correct — the digits themselves still read left to right.
Compact
.table-compact drops cell padding from
.75rem /
1rem to
.5rem /
.75rem. Use it when the table is a
reference the reader scans rather than reads, and rows-on-screen matters more than
comfort.
| Invoice | Client | Due | Amount | Status |
|---|---|---|---|---|
| INV-2291 | Ada Chen | 3 Mar | 1,240.00 | Paid |
| INV-2292 | Marco Silva | 7 Mar | 86.50 | Pending |
| INV-2293 | Priya Raman | 11 Mar | 12,900.00 | Overdue |
<div class="table-wrap">
<table class="table table-compact">
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col">Client</th>
<th scope="col">Due</th>
<th scope="col" class="num">Amount</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-2291</th>
<td>Ada Chen</td>
<td>3 Mar</td>
<td class="num">1,240.00</td>
<td><span class="badge badge-good">Paid</span></td>
</tr>
<tr>
<th scope="row">INV-2292</th>
<td>Marco Silva</td>
<td>7 Mar</td>
<td class="num">86.50</td>
<td><span class="badge badge-warn">Pending</span></td>
</tr>
<tr>
<th scope="row">INV-2293</th>
<td>Priya Raman</td>
<td>11 Mar</td>
<td class="num">12,900.00</td>
<td><span class="badge badge-bad">Overdue</span></td>
</tr>
</tbody>
</table>
</div>
<div class="table-wrap">
<table class="table table-compact">
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col">Client</th>
<th scope="col">Due</th>
<th scope="col" class="num">Amount</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-2291</th>
<td>Ada Chen</td>
<td>3 Mar</td>
<td class="num">1,240.00</td>
<td><span class="badge badge-good">Paid</span></td>
</tr>
<tr>
<th scope="row">INV-2292</th>
<td>Marco Silva</td>
<td>7 Mar</td>
<td class="num">86.50</td>
<td><span class="badge badge-warn">Pending</span></td>
</tr>
<tr>
<th scope="row">INV-2293</th>
<td>Priya Raman</td>
<td>11 Mar</td>
<td class="num">12,900.00</td>
<td><span class="badge badge-bad">Overdue</span></td>
</tr>
</tbody>
</table>
</div>
Restacking on a phone
Below 40rem, .table-stack turns each row into a small
bordered block and each cell into a label/value line. The label comes from
data-label on the cell via a ::before, so no text is
duplicated in the markup — and the <thead> is hidden visually but
left in the accessibility tree rather than removed.
Every cell needs its data-label. A cell without one renders as a value
with no label, which is worse than not restacking at all.
| Invoice | Client | Due | Amount | Status |
|---|---|---|---|---|
| INV-2291 | Ada Chen | 3 Mar | 1,240.00 | Paid |
| INV-2292 | Marco Silva | 7 Mar | 86.50 | Pending |
| INV-2293 | Priya Raman | 11 Mar | 12,900.00 | Overdue |
<div class="table-wrap">
<table class="table table-stack">
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col">Client</th>
<th scope="col">Due</th>
<th scope="col" class="num">Amount</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" data-label="Invoice">INV-2291</th>
<td data-label="Client">Ada Chen</td>
<td data-label="Due">3 Mar</td>
<td class="num" data-label="Amount">1,240.00</td>
<td data-label="Status"><span class="badge badge-good">Paid</span></td>
</tr>
<tr>
<th scope="row" data-label="Invoice">INV-2292</th>
<td data-label="Client">Marco Silva</td>
<td data-label="Due">7 Mar</td>
<td class="num" data-label="Amount">86.50</td>
<td data-label="Status"><span class="badge badge-warn">Pending</span></td>
</tr>
<tr>
<th scope="row" data-label="Invoice">INV-2293</th>
<td data-label="Client">Priya Raman</td>
<td data-label="Due">11 Mar</td>
<td class="num" data-label="Amount">12,900.00</td>
<td data-label="Status"><span class="badge badge-bad">Overdue</span></td>
</tr>
</tbody>
</table>
</div>
<div class="table-wrap">
<table class="table table-stack">
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col">Client</th>
<th scope="col">Due</th>
<th scope="col" class="num">Amount</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" data-label="Invoice">INV-2291</th>
<td data-label="Client">Ada Chen</td>
<td data-label="Due">3 Mar</td>
<td class="num" data-label="Amount">1,240.00</td>
<td data-label="Status"><span class="badge badge-good">Paid</span></td>
</tr>
<tr>
<th scope="row" data-label="Invoice">INV-2292</th>
<td data-label="Client">Marco Silva</td>
<td data-label="Due">7 Mar</td>
<td class="num" data-label="Amount">86.50</td>
<td data-label="Status"><span class="badge badge-warn">Pending</span></td>
</tr>
<tr>
<th scope="row" data-label="Invoice">INV-2293</th>
<td data-label="Client">Priya Raman</td>
<td data-label="Due">11 Mar</td>
<td class="num" data-label="Amount">12,900.00</td>
<td data-label="Status"><span class="badge badge-bad">Overdue</span></td>
</tr>
</tbody>
</table>
</div>
This is the one place Deck changes display on table elements, and it is
a real trade: the columns stop being comparable. Reach for it when the alternative
is a horizontal scrollbar the reader will not find.
Classes
Generated from src/07-components.css by
tools/docs/extract.mjs. .num is not in this table: it is
not part of the table component, because it works on any element that
holds a figure.
| Class | Layer | What it does | Source |
|---|---|---|---|
.table |
deck.components |
A real table, kept a table at every width, because values in a column are only comparable while the columns exist. | 07-components.css:241 |
.table-compact |
deck.components |
Tighter cell padding, for a table the reader scans rather than reads. | 07-components.css:264 |
.table-stack |
deck.components |
Below 40rem, restacks each row into labelled lines, taking the label from data-label on the cell. Every cell needs one. The thead is clipped rather than removed, so header association survives; print undoes the whole thing, because paper is never narrow. | 07-components.css:274 |
.table-wrap |
deck.components |
The scroll container for a table, and not optional. It carries the border, radius, background and overflow-x, without which a wide table pushes the whole page sideways. Add tabindex="0" when the table overflows, or a keyboard user cannot scroll it. | 07-components.css:231 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--surface-2 |
light-dark(var(--ink-25), var(--ink-900)) |
|
--surface-hover |
light-dark(var(--ink-50), var(--ink-900)) |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--r-md |
12px |
|
--r-sm |
7px |
|
--text-sm |
.8125rem |
|
--text-xs |
.75rem |
|
--text-muted |
light-dark(var(--ink-600), var(--ink-400)) |
|
--space-2 |
.5rem |
|
--space-3 |
.75rem |
|
--space-4 |
1rem |
Accessibility
-
scopeis on you. Deck styles<th>but cannot know whether it heads a column or a row. Every header needsscope="col"orscope="row", or a screen reader cannot tell a user which column the cell they are on belongs to. -
The first cell of a row should be a
<th scope="row">. Every example here does that. It gives each row a name, so navigating cell to cell announces "INV-2291, Amount, 1,240.00" rather than a bare number. -
Add a
<caption>. It is the table's accessible name..sr-onlyon it keeps it out of the visual design while leaving it in the accessibility tree — that is what the generated class tables on this site do. -
Keyboard scrolling of the wrapper.
.table-wrapscrolls horizontally but is not focusable, so a keyboard-only user cannot scroll it without a pointer. This is a real defect. Addtabindex="0"androle="region"with anaria-labelto a wrapper whose table overflows. Deck does not addtabindexfor you, because putting a tab stop on every table wrapper whether or not it overflows is its own problem. -
The restack keeps the header in the tree.
.table-stack theadis hidden with the clip-path technique rather thandisplay: none, so header/cell association survives on a phone. -
The sticky header can cover a focused cell. With a link in the
first visible row, keyboard focus can land underneath the sticky
<th>. There is noscroll-marginon table cells to prevent it; addscroll-margin-block-startto the row if the table has focusable content. -
Hover is not focus.
.table tbody tr:hoverhas no:focus-withincounterpart, so a keyboard user gets no row highlight. Add one inapp.componentsif rows contain controls.
Right to left
Cells use text-align: start and .num uses
end, so the whole table mirrors on dir="rtl" with no extra
rules: the first column moves to the right, and amounts align to the left edge of
their column. Row borders are border-block-start, which is unaffected.
| العميل | المبلغ |
|---|---|
| آدا تشين | 1,240.00 |
| ماركو سيلفا | 86.50 |
<div dir="rtl" class="table-wrap">
<table class="table">
<thead><tr><th scope="col">العميل</th><th scope="col" class="num">المبلغ</th></tr></thead>
<tbody>
<tr><th scope="row">آدا تشين</th><td class="num">1,240.00</td></tr>
<tr><th scope="row">ماركو سيلفا</th><td class="num">86.50</td></tr>
</tbody>
</table>
</div>
<div dir="rtl" class="table-wrap">
<table class="table">
<thead><tr><th scope="col">العميل</th><th scope="col" class="num">المبلغ</th></tr></thead>
<tbody>
<tr><th scope="row">آدا تشين</th><td class="num">1,240.00</td></tr>
<tr><th scope="row">ماركو سيلفا</th><td class="num">86.50</td></tr>
</tbody>
</table>
</div>
Reduced motion
The only animated property is the row hover background, which is not transitioned at
all — it changes immediately. There is nothing for
prefers-reduced-motion to reduce.
Printing
Print is where the table's design pays off, and
src/99-print.css does more work here than anywhere else — twelve rules:
-
display: table-header-groupon<thead>, so the header repeats at the top of every printed page. -
break-inside: avoidon every row, so a row never splits across two sheets. -
.table-stackis undone —display: table,table-row,table-cellrestored and the::beforelabels set tocontent: none. Paper is never narrow, so the phone restack would only waste it. -
font-size: 9.5ptand full width, with the header keeping a grey fill throughprint-color-adjust: exact.
Overriding it
No local custom properties. Override in app.components, which beats
every Deck layer regardless of specificity.
@layer app.components {
/* Zebra striping, which Deck deliberately does not ship */
.table tbody tr:nth-child(even) { background: var(--surface-2); }
/* A row highlight for keyboard users, which Deck is missing */
.table tbody tr:focus-within { background: var(--surface-hover); }
}
Zebra striping is left out on purpose: with a hover background and a one-pixel row border already present, a third background makes three competing signals. It is two lines to add when a table is wide enough to need it.
When not to use it
-
.tableversus.dg. The data grid brings sorting, column pinning, selection, virtual scrolling and a card mode. If you need any of those, start there..tableis for data you present;.dgis for data the reader operates on. -
Not for layout. Obvious, still worth saying. Use
.gridor.split. -
Not for a list of one-line items. A two-column table of label and
value is a description list. Use
<dl>, or.listwith.list-titleand.list-trail. -
Not when only one row is ever read. If the reader never compares
values down a column, the table's structure costs them scrolling and gains them
nothing. That is what
.listis for. -
.table-stackis not free. It destroys column comparison at exactly the width where a reader is most likely to be checking one number against another. Consider letting the table scroll horizontally instead — withtabindex="0"on the wrapper so the keyboard can reach it.