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.
| 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.
| Class | Pins to |
|---|---|
.dg-pin-start | The starting edge |
.dg-pin-start-2 | Beside it, offset by --dg-pin-start |
.dg-pin-end | The 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.
| 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.
| Invoice | Amount | |
|---|---|---|
| INV-2291 | 1,240.00 | |
| INV-2292 | 86.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.
| 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.
| 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.
| 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 bydeck.js— so it is focusable and operable without a mouse. -
Selection is announced through
aria-selectedon the row. The checkbox still needs its own accessible name — the examples here use.sr-onlytext, 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 .checkdropsmin-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-wrapisoverflow: autoand sets notabindex, so a keyboard user cannot scroll it horizontally unless a cell in the off-screen region is focusable. Addtabindex="0"androle="region"with a label. Same defect as.table-wrap. -
Column resizing is pointer-only.
.dg-resizeis 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-loadingdims the body and animates a bar; nothing is announced. Put the state in anaria-liveregion, or the grid silently goes quiet for a screen-reader user. -
.is-newis colour-only and lasts 1.4 seconds. A row arriving is not announced either. For a live grid, anaria-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.
| 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-sortyourself. - 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.