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.

The wrapper carries the border and the horizontal scroll
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>

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

Narrow the window below 40rem to see it restack
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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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

  • scope is on you. Deck styles <th> but cannot know whether it heads a column or a row. Every header needs scope="col" or scope="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-only on 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-wrap scrolls horizontally but is not focusable, so a keyboard-only user cannot scroll it without a pointer. This is a real defect. Add tabindex="0" and role="region" with an aria-label to a wrapper whose table overflows. Deck does not add tabindex for 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 thead is hidden with the clip-path technique rather than display: 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 no scroll-margin on table cells to prevent it; add scroll-margin-block-start to the row if the table has focusable content.
  • Hover is not focus. .table tbody tr:hover has no :focus-within counterpart, so a keyboard user gets no row highlight. Add one in app.components if 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.

start and end do the mirroring; the digits stay left to right
العميلالمبلغ
آدا تشين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-group on <thead>, so the header repeats at the top of every printed page.
  • break-inside: avoid on every row, so a row never splits across two sheets.
  • .table-stack is undone — display: table, table-row, table-cell restored and the ::before labels set to content: none. Paper is never narrow, so the phone restack would only waste it.
  • font-size: 9.5pt and full width, with the header keeping a grey fill through print-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

  • .table versus .dg. The data grid brings sorting, column pinning, selection, virtual scrolling and a card mode. If you need any of those, start there. .table is for data you present; .dg is for data the reader operates on.
  • Not for layout. Obvious, still worth saying. Use .grid or .split.
  • Not for a list of one-line items. A two-column table of label and value is a description list. Use <dl>, or .list with .list-title and .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 .list is for.
  • .table-stack is 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 — with tabindex="0" on the wrapper so the keyboard can reach it.