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.

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.

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

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.

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

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.

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

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.

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-editing.hc-datagrid__cellEdit mode — padding drops so the editor fills the cell.

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 reuses the --hc-tooltip-* tokens.

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>
  • 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.selected-bg / highlight-bgSelected-row / highlighted-row tint.
datagrid.current-bg / current-fgAccent for the active record’s lead cell.
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 | -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

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)