Data grid

.dg is a real <table> with a frozen header, columns that can be pinned to either edge, sorting, selection and column resizing. It is the heaviest component in Deck — 106 rules across six files — and almost all of that weight is in making a scrolling table behave.

When to use it

Use it when the reader operates on data rather than reading it: sorting, selecting rows, scanning a hundred records for one. For data you present rather than manipulate, .table is lighter and does not need JavaScript.

.dg-wrap is not optional — it carries the scroll container, the height cap, the border and the custom properties every other rule reads. The <table> goes inside it.

The wrapper scrolls; the header stays
Invoice Client Status Amount
INV-2291 Ada Chen Paid 1,240.00
INV-2292 Marco Silva Pending 86.50
INV-2293 Priya Raman Overdue 12,900.00
<div class="dg-wrap"> <table class="dg"> <thead><tr> <th scope="col">Invoice</th> <th scope="col">Client</th> <th scope="col">Status</th> <th scope="col" class="dg-num">Amount</th> </tr></thead> <tbody> <tr> <th scope="row">INV-2291</th> <td>Ada Chen</td> <td>Paid</td> <td class="dg-num">1,240.00</td> </tr> <tr> <th scope="row">INV-2292</th> <td>Marco Silva</td> <td>Pending</td> <td class="dg-num">86.50</td> </tr> <tr> <th scope="row">INV-2293</th> <td>Priya Raman</td> <td>Overdue</td> <td class="dg-num">12,900.00</td> </tr> </tbody> </table> </div>
<div class="dg-wrap">
  <table class="dg">
    <thead><tr>
      <th scope="col">Invoice</th>
      <th scope="col">Client</th>
      <th scope="col">Status</th>
      <th scope="col" class="dg-num">Amount</th>
    </tr></thead>
    <tbody>
      <tr>
        <th scope="row">INV-2291</th>
        <td>Ada Chen</td>
        <td>Paid</td>
        <td class="dg-num">1,240.00</td>
      </tr>
      <tr>
        <th scope="row">INV-2292</th>
        <td>Marco Silva</td>
        <td>Pending</td>
        <td class="dg-num">86.50</td>
      </tr>
      <tr>
        <th scope="row">INV-2293</th>
        <td>Priya Raman</td>
        <td>Overdue</td>
        <td class="dg-num">12,900.00</td>
      </tr>
    </tbody>
  </table>
</div>

Frozen header and pinned columns

The header is position: sticky with inset-block-start: 0. Pinned columns are sticky on the inline axis, and the two combine: a pinned header cell needs a higher z-index than a pinned body cell, which is why the source has four z-index rules rather than one.

Pinning classes
ClassPins to
.dg-pin-startThe starting edge
.dg-pin-start-2Beside it, offset by --dg-pin-start
.dg-pin-endThe ending edge

Put the class on every cell in the column, header included. A second pinned column needs to know how wide the first one is, and that is --dg-pin-start — measured by deck.js with a ResizeObserver, so it stays correct when the column is resized or the font changes.

The shadow that appears only when it should

A pinned column should cast a shadow when content has scrolled underneath it, and none when the grid is at rest. Traditionally that is a scroll listener toggling a class. Deck asks the scroll container directly:

@supports (container-type: scroll-state) {
  .dg-wrap { container-type: scroll-state; }
  @container scroll-state(scrollable: inline-start) {
    .dg :is(th, td).dg-pin-start:last-of-type { box-shadow: var(--dg-pin-shadow-start); }
  }
}

No handler, no class to keep in sync, and the query is already logical so RTL needs nothing. Where the feature is missing, Grid.shadows() in deck.js toggles .is-scrolled-x and .is-scrolled-end and a parallel @supports not block styles them — the same appearance from two mechanisms, with the fallback costing nothing where it is not needed.

The shadow geometry lives in four custom properties on .dg-wrap rather than in the rules, so src/19-logical.css mirrors it once for RTL instead of once per branch. That is the reason for the indirection.

Sorting

Put data-sort="num", "text" or "date" on a header cell and data-deck-grid on the wrapper. deck.js replaces the header's text with a real <button class="dg-sort"> and sorts on click, setting aria-sort on the <th>.

The styling keys off that attribute — th[aria-sort] .dg-sort { color: var(--brand) } and a rotate: 180deg for descending — so the arrow direction and the announced sort state are the same value. There is no separate .is-sorted class to fall out of step.

<div class="dg-wrap" data-deck-grid>
  <table class="dg">
    <thead><tr>
      <th scope="col" data-sort="text">Client</th>
      <th scope="col" class="dg-num" data-sort="num">Amount</th>
    </tr></thead>
    …
  </table>
</div>

Sorting is client-side and re-orders the rows in the DOM. For a grid backed by a paged API that is wrong — it sorts the page rather than the data — so leave data-sort off and sort on the server, keeping aria-sort on the header yourself so the styling still works.

Density

--dg-row-h drives the row height, and two classes set it: .dg-compact at 34px with smaller text, .dg-comfy at 56px. The default is 44px, which is the touch target.

.dg-compact — 34px rows
Invoice Client Status Amount
INV-2291 Ada Chen Paid 1,240.00
INV-2292 Marco Silva Pending 86.50
INV-2293 Priya Raman Overdue 12,900.00
<div class="dg-wrap"> <table class="dg dg-compact"> <thead><tr> <th scope="col">Invoice</th> <th scope="col">Client</th> <th scope="col">Status</th> <th scope="col" class="dg-num">Amount</th> </tr></thead> <tbody> <tr> <th scope="row">INV-2291</th> <td>Ada Chen</td> <td>Paid</td> <td class="dg-num">1,240.00</td> </tr> <tr> <th scope="row">INV-2292</th> <td>Marco Silva</td> <td>Pending</td> <td class="dg-num">86.50</td> </tr> <tr> <th scope="row">INV-2293</th> <td>Priya Raman</td> <td>Overdue</td> <td class="dg-num">12,900.00</td> </tr> </tbody> </table> </div>
<div class="dg-wrap">
  <table class="dg dg-compact">
    <thead><tr>
      <th scope="col">Invoice</th>
      <th scope="col">Client</th>
      <th scope="col">Status</th>
      <th scope="col" class="dg-num">Amount</th>
    </tr></thead>
    <tbody>
      <tr>
        <th scope="row">INV-2291</th>
        <td>Ada Chen</td>
        <td>Paid</td>
        <td class="dg-num">1,240.00</td>
      </tr>
      <tr>
        <th scope="row">INV-2292</th>
        <td>Marco Silva</td>
        <td>Pending</td>
        <td class="dg-num">86.50</td>
      </tr>
      <tr>
        <th scope="row">INV-2293</th>
        <td>Priya Raman</td>
        <td>Overdue</td>
        <td class="dg-num">12,900.00</td>
      </tr>
    </tbody>
  </table>
</div>

Cell variants are independent of density: .dg-num aligns to the end edge, .dg-center centres, .dg-tight narrows the padding, .dg-wrap-text allows wrapping with a 16rem floor, and .dg-actions shrinks a column to its content. Everything else is white-space: nowrap, which is what keeps columns aligned while the grid scrolls.

Selection

Row selection is tr[aria-selected="true"] — again the accessible state doing the styling. .dg-check is the 44px checkbox column, and it is the one place Deck deliberately strips .check's min-block-size: a dense grid of 34px rows cannot also have 44px checkboxes.

.dg-selection is the bar that appears when rows are selected, and .dg-statusbar the row-count footer.

The selected row is styled from aria-selected
Invoice Amount
INV-22911,240.00
INV-229286.50
<div class="dg-wrap"> <table class="dg"> <thead><tr> <th scope="col" class="dg-check"><label class="check"><input type="checkbox"><span class="sr-only">Select all</span></label></th> <th scope="col">Invoice</th> <th scope="col" class="dg-num">Amount</th> </tr></thead> <tbody> <tr aria-selected="true"> <td class="dg-check"><label class="check"><input type="checkbox" checked><span class="sr-only">Select INV-2291</span></label></td> <th scope="row">INV-2291</th><td class="dg-num">1,240.00</td> </tr> <tr> <td class="dg-check"><label class="check"><input type="checkbox"><span class="sr-only">Select INV-2292</span></label></td> <th scope="row">INV-2292</th><td class="dg-num">86.50</td> </tr> </tbody> </table> </div>
<div class="dg-wrap">
  <table class="dg">
    <thead><tr>
      <th scope="col" class="dg-check"><label class="check"><input type="checkbox"><span class="sr-only">Select all</span></label></th>
      <th scope="col">Invoice</th>
      <th scope="col" class="dg-num">Amount</th>
    </tr></thead>
    <tbody>
      <tr aria-selected="true">
        <td class="dg-check"><label class="check"><input type="checkbox" checked><span class="sr-only">Select INV-2291</span></label></td>
        <th scope="row">INV-2291</th><td class="dg-num">1,240.00</td>
      </tr>
      <tr>
        <td class="dg-check"><label class="check"><input type="checkbox"><span class="sr-only">Select INV-2292</span></label></td>
        <th scope="row">INV-2292</th><td class="dg-num">86.50</td>
      </tr>
    </tbody>
  </table>
</div>

Cards on a phone

.dg-cards restacks each row into a labelled card below a breakpoint, reading the column name from data-label on each cell — the same technique as .table-stack, and it carries the same cost: the columns stop being comparable.

.dg-cq does it on the container's width instead, which is the version to prefer. A grid in a split rail on a wide monitor is in a narrow space, and a viewport media query gets that wrong.

Narrow the window to see it restack
Invoice Client Status Amount
INV-2291 Ada Chen Paid 1,240.00
INV-2292 Marco Silva Pending 86.50
INV-2293 Priya Raman Overdue 12,900.00
<div class="dg-wrap dg-cards-wrap"> <table class="dg dg-cards"> <thead><tr> <th scope="col">Invoice</th> <th scope="col">Client</th> <th scope="col">Status</th> <th scope="col" class="dg-num">Amount</th> </tr></thead> <tbody> <tr> <th scope="row" data-label="Invoice">INV-2291</th> <td data-label="Client">Ada Chen</td> <td data-label="Status">Paid</td> <td class="dg-num" data-label="Amount">1,240.00</td> </tr> <tr> <th scope="row" data-label="Invoice">INV-2292</th> <td data-label="Client">Marco Silva</td> <td data-label="Status">Pending</td> <td class="dg-num" data-label="Amount">86.50</td> </tr> <tr> <th scope="row" data-label="Invoice">INV-2293</th> <td data-label="Client">Priya Raman</td> <td data-label="Status">Overdue</td> <td class="dg-num" data-label="Amount">12,900.00</td> </tr> </tbody> </table> </div>
<div class="dg-wrap dg-cards-wrap">
  <table class="dg dg-cards">
    <thead><tr>
      <th scope="col">Invoice</th>
      <th scope="col">Client</th>
      <th scope="col">Status</th>
      <th scope="col" class="dg-num">Amount</th>
    </tr></thead>
    <tbody>
      <tr>
        <th scope="row" data-label="Invoice">INV-2291</th>
        <td data-label="Client">Ada Chen</td>
        <td data-label="Status">Paid</td>
        <td class="dg-num" data-label="Amount">1,240.00</td>
      </tr>
      <tr>
        <th scope="row" data-label="Invoice">INV-2292</th>
        <td data-label="Client">Marco Silva</td>
        <td data-label="Status">Pending</td>
        <td class="dg-num" data-label="Amount">86.50</td>
      </tr>
      <tr>
        <th scope="row" data-label="Invoice">INV-2293</th>
        <td data-label="Client">Priya Raman</td>
        <td data-label="Status">Overdue</td>
        <td class="dg-num" data-label="Amount">12,900.00</td>
      </tr>
    </tbody>
  </table>
</div>

Long grids

.dg-virtual puts content-visibility: auto on every row, with contain-intrinsic-size matched per density — 34px for .dg-compact, 56px for .dg-comfy. The browser skips layout and paint for offscreen rows, and the scrollbar stays honest because the placeholder height is right.

That per-density pairing is the detail .list-virtual does not have: the list assumes 56px whatever its rows actually are. Here the two density classes each declare their own intrinsic size, so the estimate is never wrong by design.

Loading, empty and new

.is-loading on the wrapper dims the body, disables pointer events and runs an indeterminate bar across the top. .dg-empty is the row that holds an empty state. .is-new flashes a row green for 1.4 seconds when it arrives — useful for a live-updating grid, and easy to overuse.

All five is- states here are set by deck.js at runtime and are internal. .is-scrolled-x and .is-scrolled-end only exist at all as a fallback for browsers without scroll-state queries.

Classes

Generated from the stylesheet source by tools/docs/extract.mjs.

Classes, generated from the stylesheet source
Class Layer What it does Source
.dg deck.components A real table with a frozen header and pinned columns. Use it when the reader operates on data — sorts, selects, scans — rather than reads it; .table is lighter for presentation. Must sit inside .dg-wrap, which carries the scroll container and every custom property these rules read. 12-datagrid.css:29
.dg-actions deck.components No declarations. 12-datagrid.css:159
.dg-cards deck.components Below a breakpoint, restacks each row into a labelled card, taking the column name from data-label on the cell. Costs the column alignment that makes a grid comparable; prefer .dg-cq, which asks the container rather than the viewport. 12-datagrid.css:302
.dg-cards-wrap deck.components Sets max-block-size, overflow, border, background. 12-datagrid.css:296
.dg-center deck.components No declarations. 12-datagrid.css:150
.dg-check deck.components No declarations. 12-datagrid.css:156
.dg-comfy deck.components 56px rows, for a grid with two lines of content per cell. 12-datagrid.css:166
.dg-compact deck.components 34px rows and smaller text, for scanning many rows at once. 12-datagrid.css:163
.dg-cq deck.layout Sets display, inline-size. 18-container.css:101
.dg-empty deck.components The row that holds an empty state, with its height and padding released so the state can be any size. 12-datagrid.css:290
.dg-num deck.components No declarations. 12-datagrid.css:148
.dg-pin-end deck.components No declarations. 12-datagrid.css:73
.dg-pin-start deck.components No declarations. 12-datagrid.css:59
.dg-pin-start-2 deck.components No declarations. 12-datagrid.css:66
.dg-resize deck.components Column resize grip. deck.js writes the width to the th. 12-datagrid.css:198
.dg-selection deck.components Selection bar that slides in once rows are checked 12-datagrid.css:255
.dg-sort deck.components The button deck.js builds inside a th[data-sort]. The styling keys off th[aria-sort], so the arrow direction and the announced sort state are the same value and cannot drift apart. 12-datagrid.css:172
.dg-sort-icon deck.components No declarations. 12-datagrid.css:185
.dg-statusbar deck.components Sets display, align-items, gap, flex-wrap, and 3 more. 12-datagrid.css:244
.dg-tight deck.components No declarations. 12-datagrid.css:152
.dg-toolbar deck.components Sets display, align-items, gap, flex-wrap, and 1 more. 12-datagrid.css:235
.dg-virtual deck.components contain-intrinsic-size is the placeholder height for a skipped row. Get it roughly right and the scrollbar is stable; the `auto` keyword makes the browser remember the real height once a row has been rendered once. 26-perf.css:23
.dg-wrap deck.components The shadow only appears once the grid is actually scrolled sideways. A box-shadow offset is physical, so the geometry lives here and 19-logical mirrors it for RTL once, rather than once per branch below. 12-datagrid.css:10
.dg-wrap-text deck.components No declarations. 12-datagrid.css:151
.dg-zebra deck.components Zebra is opt-in, and it never fights the pinned column background 12-datagrid.css:137
.is-loading deck.reset No declarations. 02-reset.css:92
.is-new deck.components No declarations. 12-datagrid.css:140
.is-resizing deck.components No declarations. 12-datagrid.css:217
.is-scrolled-end deck.components No declarations. 12-datagrid.css:118
.is-scrolled-x deck.components No declarations. 12-datagrid.css:112

Tokens

Four local properties on .dg-wrap shape the grid: --dg-row-h, --dg-height, --dg-pin-start and --dg-pin-end. The rest are global.

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)))
--line-strong light-dark(var(--ink-300), oklch(36% .018 var(--hue-neutral)))
--brand light-dark(var(--brand-600), var(--brand-400))
--brand-soft light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand)))
--brand-soft-text light-dark(var(--brand-800), var(--brand-200))
--brand-200 oklch(88.6% calc(var(--chroma-brand) * .48) var(--hue-brand))
--brand-500 oklch(60.8% var(--chroma-brand) var(--hue-brand))
--good-100 oklch(94% calc(var(--chroma-status) * .3) var(--hue-good))
--text-sm .8125rem
--text-xs .75rem
--text-muted light-dark(var(--ink-600), var(--ink-400))
--r-md 12px
--r-sm 7px
--space-2 .5rem
--space-3 .75rem
--space-4 1rem
--hue-neutral 232

Accessibility

  • It is a real table, so the semantics are free. Header association, row and column navigation, and the row count all come from the element. Every header still needs scope, and the first cell of each row should be a <th scope="row">.
  • Sorting is announced through aria-sort, which is also what styles the arrow. The sort control is a real <button> inside the header, generated by deck.js — so it is focusable and operable without a mouse.
  • Selection is announced through aria-selected on the row. The checkbox still needs its own accessible name — the examples here use .sr-only text, because a bare checkbox in a grid is announced as "checkbox" with no indication of which row.
  • The checkbox column is under the touch target. .dg-check .check drops min-block-size, so the target is the row height — 34px in compact mode. This is a deliberate density trade and one of four such places in Deck.
  • The scroll container needs a tab stop. .dg-wrap is overflow: auto and sets no tabindex, so a keyboard user cannot scroll it horizontally unless a cell in the off-screen region is focusable. Add tabindex="0" and role="region" with a label. Same defect as .table-wrap.
  • Column resizing is pointer-only. .dg-resize is a drag handle with no keyboard equivalent. Column widths are a preference rather than content, so nothing is unreachable — but a keyboard user cannot adjust them.
  • The loading bar is decoration. .is-loading dims the body and animates a bar; nothing is announced. Put the state in an aria-live region, or the grid silently goes quiet for a screen-reader user.
  • .is-new is colour-only and lasts 1.4 seconds. A row arriving is not announced either. For a live grid, an aria-live="polite" summary — "3 new rows" — carries far more than the flash does.
  • Card mode keeps the header in the tree. Like .table-stack, the <thead> is clipped rather than removed, so header association survives the restack.

Right to left

Pinning uses inset-inline-start and inset-inline-end, cells use text-align: start, and the scroll-state queries use scrollable: inline-start — all logical, all mirrored for free.

Two things are not. A box-shadow offset is physical, and so is a clip-path inset; both live in custom properties on .dg-wrap so src/19-logical.css can mirror the geometry in one place rather than in each of the four branches that use it.

Grid.shadows() also takes Math.abs(scrollLeft), because an RTL scroller counts down from zero. That single abs is what makes the JavaScript fallback work in both directions.

Columns fill from the right; the numeric column aligns to the left
Invoice Client Status Amount
INV-2291 Ada Chen Paid 1,240.00
INV-2292 Marco Silva Pending 86.50
INV-2293 Priya Raman Overdue 12,900.00
<div dir="rtl"> <div class="dg-wrap"> <table class="dg"> <thead><tr> <th scope="col">Invoice</th> <th scope="col">Client</th> <th scope="col">Status</th> <th scope="col" class="dg-num">Amount</th> </tr></thead> <tbody> <tr> <th scope="row">INV-2291</th> <td>Ada Chen</td> <td>Paid</td> <td class="dg-num">1,240.00</td> </tr> <tr> <th scope="row">INV-2292</th> <td>Marco Silva</td> <td>Pending</td> <td class="dg-num">86.50</td> </tr> <tr> <th scope="row">INV-2293</th> <td>Priya Raman</td> <td>Overdue</td> <td class="dg-num">12,900.00</td> </tr> </tbody> </table> </div> </div>
<div dir="rtl">
<div class="dg-wrap">
  <table class="dg">
    <thead><tr>
      <th scope="col">Invoice</th>
      <th scope="col">Client</th>
      <th scope="col">Status</th>
      <th scope="col" class="dg-num">Amount</th>
    </tr></thead>
    <tbody>
      <tr>
        <th scope="row">INV-2291</th>
        <td>Ada Chen</td>
        <td>Paid</td>
        <td class="dg-num">1,240.00</td>
      </tr>
      <tr>
        <th scope="row">INV-2292</th>
        <td>Marco Silva</td>
        <td>Pending</td>
        <td class="dg-num">86.50</td>
      </tr>
      <tr>
        <th scope="row">INV-2293</th>
        <td>Priya Raman</td>
        <td>Overdue</td>
        <td class="dg-num">12,900.00</td>
      </tr>
    </tbody>
  </table>
</div>
</div>

Reduced motion

Two animations: the .is-new row flash and the .is-loading bar. Under prefers-reduced-motion: reduce the global reset collapses animations to a single .01ms iteration — but the loading bar is in the reset's exemption list alongside spinners and skeletons, so it keeps moving at 2.4 seconds rather than freezing.

That is the right call: a frozen progress indicator reads as a hung page, which is worse for everyone than a slow one.

Printing

Sixteen rules, the most of any component. The grid becomes a plain table: display: table-header-group on the <thead> so the header repeats on every page, break-inside: avoid on rows, font-size: 9.5pt, and the card mode fully unwound — display: table, table-row, table-cell, with the ::before labels set to content: none.

.dg-toolbar and .dg-resize are in the never-print list, and the header keeps its grey fill through print-color-adjust: exact.

Overriding it

/* One grid, no layer needed */
<div class="dg-wrap" style="--dg-height:40dvh;--dg-row-h:38px">

@layer app.components {
  /* A third pinned column */
  .dg :is(th, td).dg-pin-start-3 {
    position: sticky;
    inset-inline-start: calc(var(--dg-pin-start) + var(--dg-pin-start-2, 0px));
    z-index: 2;
  }

  /* A focus background for keyboard users */
  .dg tbody tr:focus-within td { background: var(--surface-hover); }
}

A third pinned column needs its own offset property measured the way --dg-pin-start is; Deck ships two because two is where the pattern stops being worth the complexity.

When not to use it

  • Not for data you only present. Use .table. The grid brings a scroll container, a height cap, six files of CSS and a JavaScript class for behaviour you are not using.
  • Not for a list of one-line items. Use .list, which has row semantics and touch targets without the column machinery.
  • Not with client-side sorting over a paged API. It sorts the visible page and looks like it sorted the data. Sort on the server and set aria-sort yourself.
  • Not with more than two pinned columns. Each one needs a measured offset, and past two the pinned region is most of a phone's screen.
  • Not as a spreadsheet. There is no cell editing, no keyboard grid navigation and no formula layer. role="grid" would promise all three; Deck's is a table, and it is announced as one.