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.
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"> <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">…</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. |
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).
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.
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 }) — persist the width with htmx or localStorage
and write it back as the column’s cell widths on the next render. The grip
is a keyboard-operable role="separator" with aria-valuenow.
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 (single column at a time, 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 }});Wire it to htmx by reloading the rows with the sort params — e.g. put
data-hx-get, data-hx-trigger="hc:datagridsort" and
data-hx-vals='js:{ sort: event.detail.col, dir: event.detail.direction }'
on the <tbody> (see the
Datagrid pager recipe). Render
each header with the current aria-sort so the indicator survives the swap.
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 |
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.
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-editing | .hc-datagrid__cell | Edit mode — padding drops so the editor fills the cell. |
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 reuses the
--hc-tooltip-* tokens.
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).
Commit: Enter, moving focus out of the cell, or — for a combobox —
picking an option. Cancel: Escape (restores the original value).
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>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.selected-bg / highlight-bg | Selected-row / highlighted-row tint. |
datagrid.current-bg / current-fg | Accent for the active record’s lead cell. |
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 | -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-hA 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)Related
Section titled “Related”- 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.