Datagrid
hc-datagrid is the heavy-duty grid for business screens: a semantic
<table> with multi-level sticky headers, frozen columns, row
selection, keyboard cell navigation, and inline editing. It is built for
paged data — htmx loads a page (tens/hundreds of rows) and the grid
renders it. It is deliberately not a client-side virtual-scroll /
sort / filter engine: sorting, filtering, and persistence stay on the
server (htmx), and the cell editors are just existing HC form controls.
Also known as: data table, Excel-like grid, spreadsheet view.
Browser baseline
Section titled “Browser baseline”| Primitive | Status |
|---|---|
position: sticky (headers + frozen columns) | Baseline (all evergreen browsers) |
:has() (optional, for adaptive layout) | Baseline 2023 |
Structure
Section titled “Structure”A standard <table> inside a scroll viewport. Group / sub / leaf headers
are ordinary <thead> rows with colspan; frozen columns carry
data-frozen (and data-frozen-edge on the last one, which casts the
freeze line). Scroll the demo horizontally — the checkbox and ID
columns stay pinned:
| ID | Group A | Group B | |||||
|---|---|---|---|---|---|---|---|
| Alpha | Beta | Gamma | Delta | Epsilon | Zeta | ||
| 1 | 456 (wide content) | 789 (wide content) | long value here | 456 | 789 | 789 | |
| 2 | 457 (wide content) | 790 (wide content) | another value | 457 | 790 | 790 | |
<div class="hc-datagrid"> <div class="hc-datagrid__scroll"> <table class="hc-datagrid__table"> <thead class="hc-datagrid__head"> <tr> <th class="hc-datagrid__headcell" data-frozen rowspan="2" scope="col">…</th> <th class="hc-datagrid__headcell" data-frozen data-frozen-edge rowspan="2" scope="col">ID</th> <th class="hc-datagrid__headcell" colspan="3">Group A</th> <th class="hc-datagrid__headcell" colspan="3">Group B</th> </tr> <tr> <th class="hc-datagrid__headcell" scope="col">Alpha</th> <!-- …leaf headers… --> </tr> </thead> <tbody class="hc-datagrid__body"> <tr class="hc-datagrid__row" aria-selected="true"> <td class="hc-datagrid__cell" data-frozen>…</td> <th class="hc-datagrid__cell" data-frozen data-frozen-edge scope="row">1</th> <td class="hc-datagrid__cell" data-highlight>…</td> </tr> </tbody> </table> </div></div>Sticky offsets
Section titled “Sticky offsets”position: sticky needs to know where to stick: each header level’s
top and each frozen column’s left. Those come from CSS variables so
they can be set to the real rendered sizes:
| Variable | On | Meaning |
|---|---|---|
--hc-datagrid-head-1-h | .hc-datagrid | Height of the group (1st) header row — the top offset of the 2nd row. |
--hc-datagrid-head-2-h | .hc-datagrid | Height of the sub (2nd) header row — added for the 3rd row’s offset. |
--hc-datagrid-left | each frozen cell | The cell’s left offset = total width of the frozen columns before it. |
--hc-datagrid-foot-1-h | .hc-datagrid | Height of the last footer row — the bottom offset of the row above it. |
--hc-datagrid-right | each data-frozen-end cell | The cell’s trailing offset = total width of the frozen-end columns after it. |
installDatagrid() measures and sets these automatically (and
re-measures on resize). For a static, script-free grid, set them
yourself: give the frozen columns fixed widths and set each frozen cell’s
--hc-datagrid-left to the cumulative width, and --hc-datagrid-head-1-h
to the group-row height (as in the demo above).
The scroll area
Section titled “The scroll area”The grid has one scroll container, .hc-datagrid__scroll, and it
holds the whole table — header, body and footer. The header does not
move because its cells are position: sticky, not because it sits
outside the scroller.
That is worth knowing for two reasons.
Sticky lives on the cells, not on the row. Measuring
.hc-datagrid__head > tr while the grid scrolls shows it moving; the
.hc-datagrid__headcell boxes are the ones that hold still. A test that
asserts on the row will report a bug that is not there.
The vertical scrollbar spans the header too. Since the scrollport starts at the top of the grid, the browser draws its scrollbar down the full height — alongside the header row, not only beside the data. Making the bar start below the header means putting the header and the body in separate scroll containers, i.e. two tables, and that costs three things this component gets for free:
- column alignment — one table lets the browser size header and body columns together; two tables must be width-synced by script, on every swap, resize and column drag;
- horizontal scroll sync — the header has to follow the body’s
scrollLefton every frame; - one accessible grid —
role="grid"derives its row/column relationships from a single table. Split it and every cell needs explicitaria-colindex/aria-rowindexto say what the markup used to say by itself.
The trade is rarely worth it. If the goal is to quiet the scrollbar rather than to move it, ask for a slimmer one — this also drops the stepper arrows that make a classic Windows scrollbar look heavy next to the header:
.hc-datagrid__scroll { scrollbar-width: thin; /* optional: scrollbar-color: <thumb> <track>; */}It is not a default because scrollbar styling is a house-style decision, and thin bars are harder to grab on touch.
Trailing frozen columns
Section titled “Trailing frozen columns”data-frozen-end is the mirror of data-frozen: the column sticks to
the trailing edge of the viewport (right in LTR, left in RTL) while
the middle columns scroll under it — the classic use is a row-actions
column that must stay reachable. data-frozen-end-edge on the first
frozen-end column casts the mirrored freeze line:
<th class="hc-datagrid__headcell" data-frozen-end data-frozen-end-edge scope="col">Actions</th>…<td class="hc-datagrid__cell" data-frozen-end data-frozen-end-edge> <button class="hc-button" data-size="sm" type="button">Edit</button></td>The behavior measures each cell’s --hc-datagrid-right (cumulative
width of the frozen-end columns after it) and keeps it correct across
column resizes and htmx swaps.
Aggregate footer
Section titled “Aggregate footer”A <tfoot class="hc-datagrid__foot"> pins to the bottom of the scroll
viewport, styled like the header band. The server computes and renders
the aggregates — page-level or query-level, the cell text says which —
and the CSS only pins them; nothing is summed client-side:
<tfoot class="hc-datagrid__foot"> <tr> <td class="hc-datagrid__cell" data-frozen>Total</td> <td class="hc-datagrid__cell" data-numeric>1,680</td> <td class="hc-datagrid__cell" data-frozen-end></td> </tr></tfoot>Footer rows stack upward (e.g. a subtotal row above the total row);
installDatagrid() measures --hc-datagrid-foot-1-h for the stacking
offset. Footer cells take data-frozen / data-frozen-end /
data-numeric like body cells, get grid-pattern roles, and stay out of
keyboard navigation — aggregates are not editable stops. Re-render the
<tfoot> alongside the rows (same fragment or an OOB swap) so the
aggregates always match the page.
Vertical headers
Section titled “Vertical headers”When a header name is much longer than its column’s data, rotate the label
instead of widening the column. Add data-orientation="vertical" to the
header cell — the label reads top-to-bottom (CJK upright, Latin rotated)
and the column stays as narrow as its data. Use it on the leaf header
row (the bottom sticky row, whose height is unconstrained):
| Product | Very long header name 2 | Discontinued flag | Reorder level |
|---|---|---|---|
| Chai | 18 | no | 10 |
| Chang | 19 | no | 25 |
<th class="hc-datagrid__headcell" data-orientation="vertical" scope="col"> Very long header name</th>Two orientations, both pure CSS — no behavior needed:
data-orientation | writing-mode | Reads | Best for |
|---|---|---|---|
vertical | vertical-rl | top → bottom (CJK upright, Latin rotated) | the safe default; mixed CJK / Japanese-first headers |
sideways | sideways-lr | bottom → top (whole line rotated) | Latin / “axis-label” style |
vertical-rl has the widest support; sideways-* is newer (Chromium /
Firefox; verify your Safari target). For full control set
--hc-datagrid-head-writing-mode yourself (e.g. sideways-rl,
vertical-lr) on the cell or the grid.
| Region | Units sold | Returns | Backorders |
|---|---|---|---|
| North | 412 | 8 | 3 |
| South | 376 | 5 | 0 |
<th class="hc-datagrid__headcell" data-orientation="sideways" scope="col"> Units sold</th>Keep any group / sub header above it horizontal; only the leaf row should be rotated so the sticky stacking stays simple.
Column resize
Section titled “Column resize”Mark a column resizable with data-resizable + data-col on its header,
and the matching data-col on that column’s body cells. installDatagrid()
adds a grip at the header’s right edge: drag it, or focus it and use the
arrow keys (Shift for a larger step). Only that column becomes
fixed-width (and clips with an ellipsis); other columns keep their
content-based width.
<thead class="hc-datagrid__head"> <tr> <th class="hc-datagrid__headcell" data-resizable data-col="name" scope="col">Name</th> <th class="hc-datagrid__headcell" scope="col">Fixed</th> </tr></thead><tbody class="hc-datagrid__body"> <tr class="hc-datagrid__row"> <td class="hc-datagrid__cell" data-col="name">Chai…</td> <td class="hc-datagrid__cell">x</td> </tr></tbody>On each change the grid dispatches hc:datagridcolumnresize
(detail: { col, width }) — and, before dispatching, mirrors the
committed width into any
input[data-hc-datagrid-width="<col>"] (in the grid’s closest <form>,
else document-wide), so an event-triggered htmx request serializes the
fresh value. The
datagrid-prefs recipe
turns that into per-user persistence; the server renders remembered
widths back as inline widths + data-resized.
Double-click the grip (or press Enter while it has
focus) to auto-size the column to its widest rendered cell — the
committed width flows through the same event/mirror pipeline. The grip
is a keyboard-operable role="separator" with aria-valuenow; it is a
JS-generated element with the class hc-datagrid__resizer, which you can
restyle like any other part class.
Sortable columns
Section titled “Sortable columns”Mark a header data-sortable (with a data-col key). The behavior makes it
focusable, toggles aria-sort on click / Enter / Space
through none → ascending → descending → none (with a ↕ / ↑ / ↓
indicator), and dispatches hc:datagridsort — the grid is
server-paged, so the server sorts and returns the page.
<th class="hc-datagrid__headcell" data-sortable data-col="price" scope="col"> Price</th>grid.addEventListener('hc:datagridsort', (e) => { // e.detail = { // col: 'price', direction: 'asc' | 'desc' | null, // sorts: [{ col, direction }, …] // the full ordered sort set // }});A plain activation is single-column (the others clear);
Shift+click / Shift+Enter adds the
column to the sort set instead. With two or more sorted columns each
header carries data-sort-index="1…n" and the indicator shows the
ordinal (↑1, ↓2). detail.sorts is the whole ordered set; the
conventional wire format is ?sort=name,-price (leading - = desc):
grid.addEventListener('hc:datagridsort', (e) => { const sort = e.detail.sorts .map((s) => (s.direction === 'desc' ? `-${s.col}` : s.col)) .join(','); // → "name,-price"});Client page sort (opt-in): data-sortable="client" sorts the
already-rendered page rows in the DOM instead of waiting for the
server — numeric when both values parse as numbers (data-value
preferred over the cell text), locale string compare otherwise.
Explicitly allowed by the depth plan for small, fully-loaded tables;
any htmx swap restores the server’s order, which is correct. The
instruction event still fires for observers. Bare data-sortable
stays server-instructed.
Wiring sort to the server
Section titled “Wiring sort to the server”Put the sort in a form field and let the form carry it:
<form id="filters" data-hx-get="/orders" data-hx-target="#rows" data-hx-trigger="submit, hc:datagridsort from:body"> <input type="hidden" name="sort" data-hc-datagrid-sort> …the filter controls…</form>installDatagrid() writes the whole ordered sort set into every
input[data-hc-datagrid-sort] in the grid’s closest <form> (or the
document) before dispatching hc:datagridsort — the same hook the
column-width prefs use
— so an event-triggered request serializes the fresh value.
Two things fall out of putting sort in the form rather than in
data-hx-vals:
- the sort survives an Apply — filtering re-submits the same form, so it no longer silently resets the order;
- a saved view captures it, because saved views store the form’s fields. A view that forgets how the list was ordered is only half a view.
Render each header with the current aria-sort (and data-sort-index
when multi-sorted) so the indicator survives the swap.
If you would rather not add the field, detail.sorts is still there for
data-hx-vals — but build the same sort=name,-price string from it,
not a single-column sort + dir pair, or multi-column sorting cannot
round-trip.
Numeric columns
Section titled “Numeric columns”Cells render digits as tabular figures
(font-variant-numeric: tabular-nums) by default, so runs of digits
align vertically down a column; the property affects digit glyphs only,
so text cells are unchanged. Right-alignment stays per-column
semantics: data-numeric on a cell / header cell end-aligns it
(text-align: end, logical — RTL flips free). It composes with
data-sortable — numeric columns are the ones most often sorted; the
sort indicator simply follows the end-aligned label.
<th class="hc-datagrid__headcell" data-numeric data-sortable data-col="amount">Amount</th>…<td class="hc-datagrid__cell" data-numeric>1,234.50</td>Inline editing a numeric cell keeps the end
alignment — the mounted editor re-inherits the cell’s text-align, so
the value doesn’t jump when entering edit mode.
Conditional formatting
Section titled “Conditional formatting”Conditional formatting is a server rule with a CSS paint: the server
evaluates the condition and renders the outcome as
data-tone="info | success | warning | error" on a cell, a row, or a
record <tbody>; the stylesheet tints it with the shared status colors
(dark-theme aware, frozen-safe gradient):
<td class="hc-datagrid__cell" data-numeric data-tone="error">-12%</td>…<tr class="hc-datagrid__row" data-tone="warning">…</tr>The tint colors come from the datagrid.tone-* tokens (referencing the
semantic status ramps) — override --hc-datagrid-tone-<tone>-bg/-fg
for a bespoke intensity. Don’t rely on the color alone: keep the value,
or an icon/text marker, in the cell (under forced colors the tint
becomes a dotted outline).
Behavior — installDatagrid()
Section titled “Behavior — installDatagrid()”import { installDatagrid } from '@hypermedia-components/core';installDatagrid(); // or the auto-init bundle: @hypermedia-components/core/behaviorsIt upgrades the server-rendered table into an interactive grid
(WAI-ARIA grid pattern):
applies role="grid" and a roving tabindex over the body cells, measures
the sticky offsets, and wires selection. It never fetches — paging and
persistence stay with htmx / the server. Idempotent, returns an
uninstaller, and picks up htmx-swapped grids and rows via
MutationObserver.
Keyboard
Section titled “Keyboard”| Key | Action |
|---|---|
| Arrow keys | Move the active cell |
| Home / End | First / last cell in the row |
| Ctrl + Home / End | First cell of the first row / last cell of the last row |
| Page Up / Down | Move by a viewport of rows |
| Space | Toggle the active row’s selection |
| Shift + Arrow / Shift + Click | Extend a cell range from the active cell |
| Ctrl/Cmd + C | Copy the range (or the active cell) as TSV |
| Ctrl/Cmd + A | Select every row on the page |
| Escape | Clear the cell range |
The grid is a single tab stop; widgets inside cells are not separate tab
stops. Space toggles the row’s checkbox and aria-selected; the header
select-all checkbox toggles every row (with an indeterminate state when
the selection is partial). Selection changes emit
hc:datagridselectionchange (detail: { selected, total }) on the grid.
Range selection & copy
Section titled “Range selection & copy”Shift+Arrow (or Shift+Click) extends a rectangular cell range from
the active cell; cells in the range carry data-in-range and paint
with the selection tint. Ctrl/Cmd+C puts the range on the clipboard
as TSV — tab-separated cells, newline-separated rows, ready to paste
into a spreadsheet. A cell spanning several slots contributes its text
once, at the first slot of the rectangle it covers.
Before writing, the grid dispatches a cancelable hc:datagridcopy
(detail: { text, rows, cols }); call preventDefault() to claim the
copy and put a richer payload on the clipboard yourself. The range is
visual state only — an htmx swap of the rows clears it, and nothing is
sent to the server.
States
Section titled “States”State is expressed with attributes, styled by the component:
| Attribute | On | Effect |
|---|---|---|
aria-selected="true" | .hc-datagrid__row | Selected-row background. |
data-active | .hc-datagrid__cell | Active-cell focus ring (set by the keyboard behavior). |
data-highlight | .hc-datagrid__cell | Column / cell highlight band. |
data-in-range | .hc-datagrid__cell | Cell-range selection tint (set by the keyboard behavior). |
data-tone | cell / row / record | Conditional-formatting tint — info / success / warning / error (server-rendered). |
data-editing | .hc-datagrid__cell | Edit mode — padding drops so the editor fills the cell. |
data-pending | .hc-datagrid__cell | Optimistic commit awaiting the row re-render (opt-in via data-hc-datagrid-pending on the grid). |
data-invalid | .hc-datagrid__cell | Server-rejected value — error ring + tint (rendered by the 422 re-render). |
data-attention | row / cell / record / head cell | This row (or column) needs the user — error (something must change) / warning (someone must decide). An edge bar that no tint can cover. |
data-alt | .hc-datagrid__row | Zebra stripe (assigned by the behavior when the grid opts in). |
State layering
Section titled “State layering”Several of those states land on the same cell at once: a failed row is hovered while being selected for a retry, a formatted value sits in a selected row. They are painted in two channels so nothing is lost.
The background channel is a single tint — only one state can own it. The order below is a ladder; the later state wins:
data-tone → :hover → data-pending → data-highlight →
data-in-range → aria-selected → :target → data-invalid
Selection outranks conditional formatting, because selection is the
state the user is manipulating right now and the formatted value stays
readable through the tint. data-invalid is last, but it only ever
covers the one offending cell — the rest of the row keeps the
selection tint, so a rejected cell in a selected row reads as both.
The attention channel never touches the background: the rejected
cell’s ring and corner flag, the data-attention edge bar, the pending
spinner. Whatever tint is painted underneath, these survive.
That is what makes a bulk-failure report workable. Mark failed rows
with data-attention="error" — not data-tone="error":
<tr class="hc-datagrid__row" id="row-101" data-attention="error" aria-selected="true">…</tr>The row keeps its selection tint (so the user can see the retry set
they are about to re-submit) and keeps its error bar. Use data-tone
for what the value means, data-attention for what the row
needs.
data-attention on a .hc-datagrid__headcell marks the offending
column, which is what makes the fault findable when the grid is
thirty columns wide and the cell is scrolled out of view.
Pick the severity from what the row needs, not from when you found
out — otherwise an unchanged row is warning before an action runs and
error after it:
error— something must change before this can proceed: a required value missing, invalid input, a wrong state (“already shipped”), no permission. A required-field check iserrorwherever it surfaces, including a pre-flight.warning— someone must decide; the value itself is fine. A ship date in the future, a discount above policy.
Zebra striping
Section titled “Zebra striping”Opt in with data-hc-zebra on the grid:
<div class="hc-datagrid" data-hc-zebra>…</div>installDatagrid() assigns data-alt to alternate rows on every
rebuild. :nth-child() cannot express this correctly:
- it counts rows hidden by a collapsed group, so the stripes shuffle the moment a group closes;
- it cannot alternate per record — a
.hc-datagrid__recordspanning three physical rows must stripe as one block, or the record stops reading as one thing.
Both are things rebuild() already knows, so the stripe is assigned
there — over visible rows, one step per record.
The stripe is the bottom rung of the ladder, so hover, selection and the
attention bar all stay visible over a striped row, and frozen columns
keep their stripe (the tint is painted over frozen-bg, which stays
opaque).
Without the opt-in the behavior leaves data-alt alone, so a server
that renders it directly works with no JavaScript at all — right for
a flat grid with no grouping or records.
Selection actions bar
Section titled “Selection actions bar”installDatagridActions() mirrors a grid’s selection into a bar that
holds bulk-action controls. The bar declares its grid with
data-hc-datagrid-actions="<selector>"; a [data-hc-datagrid-count]
child shows the translated count and the bar is hidden while nothing
is selected:
<form method="post" action="/products/bulk"> <div class="hc-toolbar" role="toolbar" aria-label="Bulk actions" data-hc-datagrid-actions="#grid" hidden> <span data-hc-datagrid-count></span> <button class="hc-button" type="submit" name="action" value="archive" data-hx-post="/products/bulk" data-hx-target="#rows" data-hx-swap="innerHTML" data-hx-disabled-elt="this">Archive</button> </div>
<div class="hc-datagrid" id="grid">…</div></form>The bar listens to the grid’s hc:datagridselectionchange events — the
initial state is read from the selection attributes at install, and the
grid re-emits after every row swap inside the tbody (the select-all
checkbox is re-synced too), so a bulk action that re-renders the rows
clears the bar without extra wiring.
The count message is the i18n key datagrid.selected
(default {selected} selected; a {total} param is also available), and
the count element gets a default role="status" so changes are announced
politely. For the full wire contract — native form serialization of the
row-checkbox ids, the server response shape, the no-JS path — see the
bulk-actions recipe.
Grouped rows
Section titled “Grouped rows”Grouping is a rendering choice: the server interleaves heading rows —
hc-datagrid__row hc-datagrid__grouprow with one colspan cell holding
the group label and any aggregates it chose to render — and the
behavior toggles the group’s rows on click / Enter /
Space. Nothing is grouped or summed client-side; collapse is
pure visibility (the rows are already on the page):
<tbody class="hc-datagrid__body"> <tr class="hc-datagrid__row hc-datagrid__grouprow" data-group-level="1"> <td class="hc-datagrid__cell" colspan="3">Fruit — Σ 30</td> </tr> <tr class="hc-datagrid__row">…</tr> <tr class="hc-datagrid__row">…</tr></tbody>- The heading’s cell carries
aria-expanded(valid ongridcell; the behavior defaults it to"true") — render it"false"to start a group collapsed. The caret (▸/▾) follows it. data-group-level="1…3"nests groups: a collapse hides everything up to the next same-or-higher heading, and re-expanding keeps collapsed sub-groups collapsed. Levels 2 and 3 indent.- Group headings join keyboard navigation as normal rows (their single spanning cell is one stop) but are not selectable units — select-all and the actions-bar count see only data rows, and collapsing never changes the selection.
- Toggling emits
hc:datagridgrouptoggle{ row, expanded }. - Grouping composes with sorting/paging as a server concern: the group
headings are just rows in the fragment, so a re-render replaces them
atomically. (Grouped layouts are for flat-row grids — with
multi-row records use one
tbodyper record instead.)
Tree rows
Section titled “Tree rows”Hierarchy as lazy hypermedia: every row carries aria-level, an
expandable row carries aria-expanded + a data-hc-datagrid-tree
toggle in its lead cell, and children are sibling rows one level deeper
— server-rendered, or loaded once via htmx (the
datagrid-tree recipe
has the wire contract). When tree toggles exist the behavior upgrades
the table to role="treegrid" — the role under which row-level
aria-level / aria-expanded are valid. Click the toggle or press
Enter on the lead cell; collapse hides the loaded subtree
(hidden rows leave keyboard navigation), re-expanding respects
collapsed children, and toggling emits hc:datagridtreetoggle
{ row, expanded }. A lazy first expand marks the row data-loaded,
sets aria-busy, and dispatches hc:datagridtreeload for htmx.
Levels 2–4 indent the lead cell — override --hc-datagrid-indent for
a different step.
Multi-row records
Section titled “Multi-row records”Dense business screens often show one record across several rows (e.g.
Code/Product on the first line, Qty/Unit price on the second, Profit on a third).
Model each record as its own <tbody class="hc-datagrid__record"> of
sub-rows — a <table> may have many <tbody> elements — and span the lead
column (No. / select) across them with rowspan. The header is the usual
multi-level <thead>, one header row per sub-row.
| No. | Code | Product |
|---|---|---|
| Qty | Unit price | |
| 1 | D0006 | Better Roast Ham |
| 12 boxes | $14,000 | |
| 2 | D0004 | Tasty Base |
| 47 boxes | $17,250 |
<table class="hc-datagrid__table"> <thead class="hc-datagrid__head"><!-- one header row per sub-row --></thead>
<tbody class="hc-datagrid__record"> <tr class="hc-datagrid__row"> <td class="hc-datagrid__cell" rowspan="2"> <input type="checkbox" class="hc-checkbox" aria-label="Select"> 1 </td> <td class="hc-datagrid__cell">D0006</td> <td class="hc-datagrid__cell">Better Roast Ham</td> </tr> <tr class="hc-datagrid__row"> <td class="hc-datagrid__cell">12 boxes</td> <td class="hc-datagrid__cell">$14,000</td> </tr> </tbody> <!-- one <tbody class="hc-datagrid__record"> per record --></table>installDatagrid() treats each record <tbody> as a single selectable
unit: the record’s checkbox (or Space) selects all its sub-rows
(aria-selected on each, data-selected on the <tbody>), select-all and
hc:datagridselectionchange count by record, and the record holding the
active cell gets data-current (the lead rowspan cell is accented).
A thicker border separates records; sub-rows within a record are divided
by a lighter line. Keyboard navigation moves by visual position:
↑/↓ stay in the same visual column while crossing sub-rows and records
(rowspan/colspan are resolved, so ↓ then ↑ returns to the starting
cell), and a spanning cell — like the lead rowspan cell — is a single
stop reachable with ←/→ from any sub-row it spans. Single-row grids
(one <tbody class="hc-datagrid__body">) are unchanged.
Expandable row detail
Section titled “Expandable row detail”Give a record a collapse / expand toggle and reveal an arbitrary-HTML
detail panel — a nested grid, a form, a chart. Put a
[data-hc-datagrid-toggle] button in the record’s lead cell and add a
.hc-datagrid__detail-row (a <tr> with one colspan cell) as the last
row of the record <tbody>. Click the +/− button (or press Enter on its
cell) to toggle:
| Detail | Category | Description |
|---|---|---|
| Beverages | Soft drinks, coffees, teas | |
Detail panel — any HTML here (a nested table, form, chart, …). | ||
<tbody class="hc-datagrid__record"> <tr class="hc-datagrid__row"> <td class="hc-datagrid__cell"> <button class="hc-datagrid__toggle" data-hc-datagrid-toggle type="button" aria-label="Detail"></button> </td> <td class="hc-datagrid__cell">Beverages</td> <td class="hc-datagrid__cell">Soft drinks, coffees, teas…</td> </tr> <tr class="hc-datagrid__detail-row"> <td class="hc-datagrid__detail" colspan="3"> <!-- any HTML: a nested hc-datagrid, a form, a chart --> </td> </tr></tbody>installDatagrid() toggles data-expanded on the record, shows/hides the
detail row, keeps aria-expanded / aria-controls in sync, and dispatches
hc:datagridexpand / hc:datagridcollapse (detail: { record }). Start a
record open by putting data-expanded on its <tbody>. A nested
hc-datagrid in a detail panel is upgraded and operated independently —
the outer grid ignores events bubbling from it.
Lazy-load with htmx — add data-lazy to the detail cell. On the first
expand the behavior fires hc:datagriddetailload on the cell and shows a
busy spinner (aria-busy="true"); wire htmx to that event to fetch the
content, and the spinner clears as soon as the content swaps in. Re-expanding
does not reload.
<tr class="hc-datagrid__detail-row"> <td class="hc-datagrid__detail" colspan="3" data-lazy data-hx-get="/categories/1/products" data-hx-trigger="hc:datagriddetailload" data-hx-target="this" data-hx-swap="innerHTML"> <!-- filled on first expand --> </td></tr>Truncation & overflow tooltip
Section titled “Truncation & overflow tooltip”When a value is too wide for its column, clip it to one line with an
ellipsis and reveal the full text on hover/focus. Wrap the value in
.hc-datagrid__truncate and give it a fixed width (the column’s content
width) via --hc-datagrid-truncate-max or an inline max-inline-size:
<td class="hc-datagrid__cell"> <span class="hc-datagrid__truncate" style="max-inline-size: 12rem"> Data 1 xxxxxxxxxxxxxx </span></td>The fixed width is what makes truncation work: it caps the column’s
max-content so the table doesn’t simply grow to fit. installDatagrid()
watches these elements and, only when the text is actually clipped
(scrollWidth > clientWidth), shows the full value in a single shared,
styled tooltip on hover and keyboard focus — so it scales to a grid
of hundreds of cells without a tooltip per cell. The tooltip is a
JS-generated element with the class hc-datagrid__tooltip and reuses
the --hc-tooltip-* tokens.
A cell that carries its own message — a server-rendered
data-invalid, or an aria-describedby pointing at an
hc-tooltip — suppresses
the overflow tooltip: two meanings on one hover would be a bug, and the
error wins. Widen the column if the clipped text also has to be
readable.
Row ordinals — data-row-no
Section titled “Row ordinals — data-row-no”A business grid is discussed out loud: “row 137 is the one that failed.” The record id is right for the system and wrong for the sentence, so the grid can carry a position as well.
The server numbers the result set; the behavior derives the ARIA numbers, which count DOM rows including header rows:
<div class="hc-datagrid" data-row-total="5000"> … <tr class="hc-datagrid__row" id="row-4901" data-row-no="137"> <td class="hc-datagrid__cell" data-numeric>137</td> <th class="hc-datagrid__cell" scope="row"><a href="/orders/4901">SO-4901</a></th>installDatagrid() writes aria-rowcount on the table and
aria-rowindex on the header rows and every numbered row, adding the
header offset for you. Getting that offset wrong is an off-by-header
nobody notices without a screen reader, which is why the server is
never asked for it.
Two rules keep the number honest:
- The ordinal is a locator; the id is the identity. Ordinals move
the moment the sort or the conditions change, so anything stored —
a bulk-error report, a saved link — names the id and merely displays
the ordinal:
SO-4901 (row 137). - It counts the result set, not the page. Row 12 of page 2 is row
52. Without
aria-rowcount/aria-rowindexa paged grid announces “row 3 of 40” on page four, which is simply false; with them the announcement matches what the screen says.
data-row-total omitted means unknown — the table gets
aria-rowcount="-1", the honest answer while an
infinite grid is
still loading. A row without data-row-no (a group header, a
client-inserted tree child) is left unnumbered rather than given a
position it does not have.
Fragment navigation
Section titled “Fragment navigation”A link to a row (#row-101 — from a bulk-error report entry, or a deep
link into a page) scrolls it into view natively, so browser history —
and therefore Back — keeps working. Two things make the landing
usable:
installDatagrid()moves the active cell to that row’s first cell and focuses it, so keyboard and screen-reader users arrive where the eye does and can keep arrowing. It runs on load and onhashchange; a hash naming nothing in this grid is ignored.- The hash may name a cell instead (
#cell-101-ship-date). Landing on the row is not enough when the grid is wide: the user still has to find which of thirty columns was rejected, and that column may be scrolled out of view. A cell link lands on it, scrolling both axes (clear of the sticky header and the frozen columns). Link failures to the cell whenever the server knows which column is at fault. - The row is emphasised with
:target(persistent, not a flash) and carriesscroll-margin-block-startderived from the measured header heights, so it does not land under the sticky header.
Rows need stable ids for this — server-render them
(id="row-<id>"); a row on another page is reached by a real URL
(/items?focus=101#row-101), letting the server render the page that
contains it.
Editability states
Section titled “Editability states”A business grid has to answer three questions before the user touches a
cell — can I edit this, must it have a value, is it locked —
and gridcell supports the vocabulary for all three. The behavior
derives it from what you already wrote, so there is nothing extra to
author:
| State | Announced as | Derived from |
|---|---|---|
| editable + required | aria-required="true" | the column editor template’s control carrying required |
| editable + optional | neither attribute | data-editable + a matching editor template |
| locked | aria-readonly="true" | the absence of data-editable |
Three rules keep it honest:
- A server-rendered value always wins. Requiredness that depends on
the row (“required only while the order is open”) is a server rule —
render
aria-requiredon the cell and the behavior leaves it alone. - A wholly read-only grid says so once, on the grid element, rather than repeating itself on every cell.
- Editability is per cell, so row state works out of the box.
Unshipped rows editable, shipped rows locked is just the server not
rendering
data-editableon the locked ones; a row swap re-derives everything, so the announcement follows the data.
Affordance
Section titled “Affordance”Editable cells get a hover / focus affordance by default (cursor + a subtle inset border) — discoverable exactly when the user is looking at the cell, and silent at rest, which matters at 200 rows. For a standing mark, opt in per grid and mark the exception:
<!-- mostly read-only grid → mark what CAN be edited --><div class="hc-datagrid" data-hc-editable-hint="editable">
<!-- mostly editable grid → sink what cannot --><div class="hc-datagrid" data-hc-editable-hint="readonly">Required-ness is marked with a * wherever aria-required="true"
sits: put it on the column header when the whole column is required
(said once, survives printing), and on the cell when it varies by
row. The server decides which, because only it knows the rule. Colour
is never the only channel — the marker is text and the state is in the
accessibility tree either way.
“Required but empty” is a row error, not a column property: use the
server-rendered data-invalid / data-tone vocabulary from
edit feedback for that.
Inline editing
Section titled “Inline editing”Editing reuses existing HC form controls rather than a bespoke editor
engine. An editable cell carries data-editable and a data-col
naming its column (and, for coded values, a data-value). The column’s
editor is a <template data-datagrid-editor data-col="…"> holding the
control; installDatagrid() clones it into the cell on activation, seeds
it from the cell’s current value, and focuses it:
<div class="hc-datagrid"> <template data-datagrid-editor data-col="qty"> <input class="hc-input" type="text" aria-label="Quantity"> </template> <template data-datagrid-editor data-col="code"> <!-- a searchable select, reusing hc-combobox --> <div class="hc-combobox"> <input class="hc-combobox__input hc-input" role="combobox" aria-controls="code-list" aria-haspopup="listbox" autocomplete="off"> <ul class="hc-combobox__listbox" id="code-list" role="listbox" popover> <li class="hc-combobox__option" role="option" data-value="001">Code A</li> <li class="hc-combobox__option" role="option" data-value="002">Code B</li> </ul> </div> </template>
<div class="hc-datagrid__scroll"> <table class="hc-datagrid__table"> <!-- … --> <td class="hc-datagrid__cell" data-editable data-col="qty">3</td> <td class="hc-datagrid__cell" data-editable data-col="code" data-value="001">Code A</td> </table> </div></div>Map editor types to the controls you already use: text → hc-input,
date → hc-input[type=date], select → hc-select, searchable select →
hc-combobox. (The combobox’s listbox uses popover, so its dropdown
escapes the grid’s scroll clipping.)
Activation: Enter / F2 / double-click the active editable cell, or
just start typing (the first character seeds the editor, Excel-style).
Typing through an IME also works: composition keystrokes open the editor
unseeded and hand the composition to the focused input, so CJK input is
never swallowed by the cell.
Commit: Enter, moving focus out of the cell, or — for a combobox —
picking an option. Cancel: Escape (restores the original value).
A row replaced while its editor is open — an SSE update, another user’s change, a pager refresh — discards the open edit: the row it belonged to is gone. The grid drops the editing state so keyboard navigation keeps working on the new rows. If silent loss is unacceptable, pair remote row updates with the edit-conflict contract so the commit is refused rather than overwritten.
Validation: the editor control’s native constraints are the API —
give the template’s input required, pattern, min / max or
maxlength, and an invalid value blocks the commit: the editor stays
open showing the native message (reportValidity()), nothing is
written back, and no event fires. Escape still cancels. Server-side
rejection stays the
inline-edit recipe’s 422
contract.
On commit the value is written back (data-value + the cell’s display
text) and the grid dispatches hc:datagridedit with
detail: { cell, col, value, label, oldValue }. Persist it with htmx —
e.g. on the row or grid:
<tbody class="hc-datagrid__body" data-hx-trigger="hc:datagridedit" data-hx-patch="/rows" data-hx-include="closest tr"> …</tbody>Edit feedback
Section titled “Edit feedback”The commit is optimistic — the cell shows the new value before the server has answered. The feedback loop closes with three additive pieces (the edit-feedback plan):
-
data-pending— withdata-hc-datagrid-pendingon the grid wrapper, a changed commit marks the edited celldata-pending+aria-busy="true"(busy tint + spinner) until the server’s row re-render replaces it. Opt-in on purpose: it assumes the re-render contract — without persistence wiring nothing would clear it. -
data-invalid— the server’s422re-render marks the rejected cell (renderaria-invalid="true"andaria-describedbypointing at the error row’s message too). The cell shows the server’s value; the error row preserves what was submitted. PressEnterto re-edit — the editor is not reopened automatically. -
Markers never change the column width. The table is sized by
max-contentand cells do not wrap, so any inline addition to a cell — a spinner, a badge, a “details” link — widens that column and shifts the layout (measured: 76 px → 121 px for one small link), and in adata-resizedcolumn it is clipped away instead. The grid’s own markers are therefore absolutely positioned: the saving spinner, the rejected-cell corner flag, and the per-cell required*all sit in the cell’s padding gutter at zero layout cost. Follow the same rule for your own markers, and keep explanatory links in the report or a dedicated column rather than inside a data cell. -
.hc-datagrid__error-row— the message slot: a server-rendered<tr>directly under the row with onecolspan.hc-datagrid__errorcell; putrole="alert"on an inner element so it announces without stealing focus. Like the detail row, it stays out of keyboard navigation. -
data-attention="warning"— the fourth outcome: the value is acceptable but unusual (a ship date in the future, a discount above policy), so the server answers200with the proposed value in the cell and adata-tone="warning"message row offering Confirm and Cancel. Nothing is committed yet. Do not usedata-pendingfor this: that state means “waiting for the server” and draws a spinner, and here the server is waiting for the user.
The datagrid-edit-errors and datagrid-edit-conflict recipes carry the full 422 / 409 wire contracts, and the first also carries the confirmable-warning branch (including why the confirmation token must be bound to the value).
Events
Section titled “Events”Every event is a bubbling CustomEvent, so one listener on the grid (or
an ancestor, or an htmx data-hx-trigger) sees everything. All dispatch
on the .hc-datagrid root except hc:datagriddetailload:
| Event | Dispatches on | detail |
|---|---|---|
hc:datagridsort | .hc-datagrid | { col, direction, sorts } — direction 'asc' / 'desc' / null; sorts = the full ordered sort set. |
hc:datagridcolumnresize | .hc-datagrid | { col, width } — the new width in px. |
hc:datagridcopy | .hc-datagrid | { text, rows, cols } — cancelable; preventDefault() claims the clipboard write. |
hc:datagridselectionchange | .hc-datagrid | { selected, total } — counted by record/row unit. |
hc:datagridedit | the edited cell (bubbles through row / record / grid) | { cell, col, value, label, oldValue } — commit only, and only when the value changed. |
hc:datagridgrouptoggle | .hc-datagrid | { row, expanded } — the toggled .hc-datagrid__grouprow. |
hc:datagridtreetoggle | .hc-datagrid | { row, expanded } — the toggled tree row. |
hc:datagridtreeload | the lazy tree .hc-datagrid__row | { row } — first expand only (htmx loads via data-hx-trigger). |
hc:datagridexpand | .hc-datagrid | { record } — the expanded record <tbody>. |
hc:datagridcollapse | .hc-datagrid | { record } — the collapsed record <tbody>. |
hc:datagriddetailload | the [data-lazy] .hc-datagrid__detail cell | { record } — first expand only. |
Feature attributes
Section titled “Feature attributes”The authored data-* surface at a glance — per feature, and whether it
works with the stylesheet alone or needs installDatagrid():
| Feature | Attributes (where) | Needs |
|---|---|---|
| Frozen columns | data-frozen, data-frozen-edge (head/body cells) | CSS — the behavior only automates the --hc-datagrid-left / header-height offsets. |
| Trailing frozen columns | data-frozen-end, data-frozen-end-edge (head/body/foot cells) | CSS — the behavior only automates the --hc-datagrid-right offsets. |
| Aggregate footer | <tfoot class="hc-datagrid__foot"> (server-rendered) | CSS — the behavior only measures --hc-datagrid-foot-1-h and applies roles. |
| Vertical headers | data-orientation — vertical / sideways (header cell) | CSS |
| Column / cell highlight | data-highlight (cell) | CSS |
| Numeric columns | data-numeric (head/body cells) | CSS |
| Row ordinals | data-row-no (row), data-row-total (grid) | installDatagrid() — derives aria-rowindex / aria-rowcount, header offset included. |
| Conditional formatting | data-tone — info / success / warning / error (cell / row / record) | CSS |
| Zebra striping | data-hc-zebra (grid) — or server-rendered data-alt (row) | installDatagrid() for the dynamic cases; CSS alone if the server assigns data-alt |
| Column resize | data-resizable + data-col (header) · matching data-col (body cells) | installDatagrid() |
| Sortable columns | data-sortable + data-col (header) — the value "client" sorts the rendered page in the DOM | installDatagrid() |
| Sort → the wire | <input data-hc-datagrid-sort> in the grid’s form — receives the ordered sort=name,-price set before hc:datagridsort fires | installDatagrid() |
| Grouped rows | .hc-datagrid__grouprow + data-group-level (heading row) · aria-expanded (heading cell, start collapsed with "false") | installDatagrid() |
| Tree rows | aria-level (every row) · aria-expanded + data-hc-datagrid-tree toggle (expandable row) · data-lazy + htmx wiring (lazy row) | installDatagrid() |
| Expandable row detail | data-hc-datagrid-toggle (button) · data-expanded (record <tbody>, start open) · data-lazy (detail cell) | installDatagrid() |
| Inline editing | data-editable + data-col (+ data-value) (cell) · data-datagrid-editor + data-col (<template>) | installDatagrid() |
| Editability states | derived aria-required / aria-readonly (cell or grid; a server-rendered value wins) · data-hc-editable-hint — editable / readonly (grid) | installDatagrid() |
| Selection actions bar | data-hc-datagrid-actions="<selector>" (bar) · data-hc-datagrid-count (count element) | installDatagridActions() |
The behavior also writes state attributes for the CSS — data-active,
data-in-range, data-editing, data-resized on cells;
data-selected, data-current, data-expanded on records (see
States).
Accessibility
Section titled “Accessibility”- It is a real
<table>with<thead>/<tbody>,scopeon header cells, andaria-labels on the row-select checkboxes — so it is meaningful without any script. installDatagrid()adds full keyboard cell navigation following the WAI-ARIA grid pattern (see Behavior above).- The scroll viewport contains focusable controls (the checkboxes), so it is keyboard-reachable.
Theming tokens
Section titled “Theming tokens”Component tokens (in component.tokens.json). They reference the shared
semantic colors, so the grid follows the active light / dark and color
theme automatically.
| Token path | Purpose |
|---|---|
datagrid.bg / fg / border | Grid surface, text, and outer border. |
datagrid.head-bg / head-fg | Header band colors. |
datagrid.frozen-bg | Background of frozen (sticky) columns. |
datagrid.row-hover-bg | Hovered-row tint. |
datagrid.row-alt-bg | Zebra-stripe tint of data-alt rows. |
datagrid.selected-bg / highlight-bg | Selected-row / highlighted-row tint. |
datagrid.current-bg / current-fg | Accent for the active record’s lead cell. |
datagrid.tone-<tone>-bg / -fg | Conditional-formatting tints (info / success / warning / error), referencing the semantic status colors. |
datagrid.attention-error-bg / attention-warning-bg | Color of the data-attention edge bar per level. |
datagrid.subrow-border | Line between a record’s sub-rows. |
datagrid.cell-padding-x / cell-padding-y | Cell padding. |
datagrid.head-1-h / head-2-h | Fixed heights of the non-leaf header levels (so the level below can offset its sticky top). |
CSS variables
Section titled “CSS variables”Show the generated CSS variables
Generated from the datagrid.* tokens above — override any of them at
your chosen scope for a custom look (the blue headers / tinted columns
seen in admin apps are just overrides):
--hc-datagrid-bg | -fg | -border--hc-datagrid-head-bg | -head-fg | -frozen-bg--hc-datagrid-row-hover-bg | -row-alt-bg | -selected-bg | -highlight-bg--hc-datagrid-current-bg | -current-fg | -subrow-border--hc-datagrid-cell-padding-x | -cell-padding-y--hc-datagrid-head-1-h | -head-2-h--hc-datagrid-tone-info-bg | … (per-tone -bg / -fg: info, success, warning, error)--hc-datagrid-attention-error-bg | -attention-warning-bgA few knobs are not token-backed — set them directly:
--hc-datagrid-max-height (default 70vh)--hc-datagrid-truncate-max (default 16rem — width of a .hc-datagrid__truncate cell)--hc-datagrid-freeze-shadow (frozen-column edge shadow; its direction flips per edge)--hc-datagrid-freeze-end-shadow (the frozen-end mirror of the freeze shadow)--hc-datagrid-foot-shadow (upward shadow cast by the sticky footer)--hc-datagrid-attention-color (the data-attention accent — set per level from the -attention-*-bg tokens)--hc-datagrid-attention-bar (the inset edge-bar shadow painted on data-attention rows)Related
Section titled “Related”- Data grid guide — the map of the whole subsystem: this component, the page template, and the thirteen recipes, in build order.
- Datagrid pager recipe — server pagination with htmx (this grid is built for paged data).
- Table — the static semantic table for simple, non-interactive data.
- Layout utilities · Responsive design.
Used in recipes: Datagrid bulk actions · Datagrid bulk errors · Datagrid columns · Datagrid edit errors · Datagrid filter · Datagrid infinite scroll · Datagrid pager · Datagrid prefs · Datagrid sort · Datagrid tree · Row detail · SSE live updates