Skip to content

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.

PrimitiveStatus
position: sticky (headers + frozen columns)Baseline (all evergreen browsers)
:has() (optional, for adaptive layout)Baseline 2023

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:

IDGroup AGroup B
AlphaBetaGammaDeltaEpsilonZeta
1456 (wide content)789 (wide content)long value here456789789
2457 (wide content)790 (wide content)another value457790790

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:

VariableOnMeaning
--hc-datagrid-head-1-h.hc-datagridHeight of the group (1st) header row — the top offset of the 2nd row.
--hc-datagrid-head-2-h.hc-datagridHeight of the sub (2nd) header row — added for the 3rd row’s offset.
--hc-datagrid-lefteach frozen cellThe cell’s left offset = total width of the frozen columns before it.
--hc-datagrid-foot-1-h.hc-datagridHeight of the last footer row — the bottom offset of the row above it.
--hc-datagrid-righteach data-frozen-end cellThe 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 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 scrollLeft on every frame;
  • one accessible gridrole="grid" derives its row/column relationships from a single table. Split it and every cell needs explicit aria-colindex / aria-rowindex to 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.

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.

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.

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):

ProductVery long header name 2Discontinued flagReorder level
Chai18no10
Chang19no25

Two orientations, both pure CSS — no behavior needed:

data-orientationwriting-modeReadsBest for
verticalvertical-rltop → bottom (CJK upright, Latin rotated)the safe default; mixed CJK / Japanese-first headers
sidewayssideways-lrbottom → 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.

RegionUnits soldReturnsBackorders
North41283
South37650

Keep any group / sub header above it horizontal; only the leaf row should be rotated so the sticky stacking stays simple.

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.

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.

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.

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

import { installDatagrid } from '@hypermedia-components/core';
installDatagrid(); // or the auto-init bundle: @hypermedia-components/core/behaviors

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

KeyAction
Arrow keysMove the active cell
Home / EndFirst / last cell in the row
Ctrl + Home / EndFirst cell of the first row / last cell of the last row
Page Up / DownMove by a viewport of rows
SpaceToggle the active row’s selection
Shift + Arrow / Shift + ClickExtend a cell range from the active cell
Ctrl/Cmd + CCopy the range (or the active cell) as TSV
Ctrl/Cmd + ASelect every row on the page
EscapeClear 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.

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.

State is expressed with attributes, styled by the component:

AttributeOnEffect
aria-selected="true".hc-datagrid__rowSelected-row background.
data-active.hc-datagrid__cellActive-cell focus ring (set by the keyboard behavior).
data-highlight.hc-datagrid__cellColumn / cell highlight band.
data-in-range.hc-datagrid__cellCell-range selection tint (set by the keyboard behavior).
data-tonecell / row / recordConditional-formatting tint — info / success / warning / error (server-rendered).
data-editing.hc-datagrid__cellEdit mode — padding drops so the editor fills the cell.
data-pending.hc-datagrid__cellOptimistic commit awaiting the row re-render (opt-in via data-hc-datagrid-pending on the grid).
data-invalid.hc-datagrid__cellServer-rejected value — error ring + tint (rendered by the 422 re-render).
data-attentionrow / cell / record / head cellThis 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__rowZebra stripe (assigned by the behavior when the grid opts in).

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:hoverdata-pendingdata-highlightdata-in-rangearia-selected:targetdata-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 is error wherever 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.

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

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.

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 on gridcell; 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 tbody per record instead.)

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.

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.CodeProduct
QtyUnit price
1D0006Better Roast Ham
12 boxes$14,000
2D0004Tasty Base
47 boxes$17,250

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.

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:

DetailCategoryDescription
BeveragesSoft drinks, coffees, teas

Detail panel — any HTML here (a nested table, form, chart, …).

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>

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.

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

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 on hashchange; 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 carries scroll-margin-block-start derived 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.

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:

StateAnnounced asDerived from
editable + requiredaria-required="true"the column editor template’s control carrying required
editable + optionalneither attributedata-editable + a matching editor template
lockedaria-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-required on 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-editable on the locked ones; a row swap re-derives everything, so the announcement follows the data.

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.

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>

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 — with data-hc-datagrid-pending on the grid wrapper, a changed commit marks the edited cell data-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’s 422 re-render marks the rejected cell (render aria-invalid="true" and aria-describedby pointing at the error row’s message too). The cell shows the server’s value; the error row preserves what was submitted. Press Enter to re-edit — the editor is not reopened automatically.

  • Markers never change the column width. The table is sized by max-content and 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 a data-resized column 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 one colspan .hc-datagrid__error cell; put role="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 answers 200 with the proposed value in the cell and a data-tone="warning" message row offering Confirm and Cancel. Nothing is committed yet. Do not use data-pending for 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).

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:

EventDispatches ondetail
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:datagrideditthe 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:datagridtreeloadthe 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:datagriddetailloadthe [data-lazy] .hc-datagrid__detail cell{ record } — first expand only.

The authored data-* surface at a glance — per feature, and whether it works with the stylesheet alone or needs installDatagrid():

FeatureAttributes (where)Needs
Frozen columnsdata-frozen, data-frozen-edge (head/body cells)CSS — the behavior only automates the --hc-datagrid-left / header-height offsets.
Trailing frozen columnsdata-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 headersdata-orientationvertical / sideways (header cell)CSS
Column / cell highlightdata-highlight (cell)CSS
Numeric columnsdata-numeric (head/body cells)CSS
Row ordinalsdata-row-no (row), data-row-total (grid)installDatagrid() — derives aria-rowindex / aria-rowcount, header offset included.
Conditional formattingdata-toneinfo / success / warning / error (cell / row / record)CSS
Zebra stripingdata-hc-zebra (grid) — or server-rendered data-alt (row)installDatagrid() for the dynamic cases; CSS alone if the server assigns data-alt
Column resizedata-resizable + data-col (header) · matching data-col (body cells)installDatagrid()
Sortable columnsdata-sortable + data-col (header) — the value "client" sorts the rendered page in the DOMinstallDatagrid()
Sort → the wire<input data-hc-datagrid-sort> in the grid’s form — receives the ordered sort=name,-price set before hc:datagridsort firesinstallDatagrid()
Grouped rows.hc-datagrid__grouprow + data-group-level (heading row) · aria-expanded (heading cell, start collapsed with "false")installDatagrid()
Tree rowsaria-level (every row) · aria-expanded + data-hc-datagrid-tree toggle (expandable row) · data-lazy + htmx wiring (lazy row)installDatagrid()
Expandable row detaildata-hc-datagrid-toggle (button) · data-expanded (record <tbody>, start open) · data-lazy (detail cell)installDatagrid()
Inline editingdata-editable + data-col (+ data-value) (cell) · data-datagrid-editor + data-col (<template>)installDatagrid()
Editability statesderived aria-required / aria-readonly (cell or grid; a server-rendered value wins) · data-hc-editable-hinteditable / readonly (grid)installDatagrid()
Selection actions bardata-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).

  • It is a real <table> with <thead> / <tbody>, scope on header cells, and aria-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.

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 pathPurpose
datagrid.bg / fg / borderGrid surface, text, and outer border.
datagrid.head-bg / head-fgHeader band colors.
datagrid.frozen-bgBackground of frozen (sticky) columns.
datagrid.row-hover-bgHovered-row tint.
datagrid.row-alt-bgZebra-stripe tint of data-alt rows.
datagrid.selected-bg / highlight-bgSelected-row / highlighted-row tint.
datagrid.current-bg / current-fgAccent for the active record’s lead cell.
datagrid.tone-<tone>-bg / -fgConditional-formatting tints (info / success / warning / error), referencing the semantic status colors.
datagrid.attention-error-bg / attention-warning-bgColor of the data-attention edge bar per level.
datagrid.subrow-borderLine between a record’s sub-rows.
datagrid.cell-padding-x / cell-padding-yCell padding.
datagrid.head-1-h / head-2-hFixed heights of the non-leaf header levels (so the level below can offset its sticky top).
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-bg

A 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)

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