HyDatagrid
Usage examples on this page are written for Lit / plain HTML (
<hy-datagrid>). The same component ships asHyDatagridin@whitespaceux/harmony-react(native React 19), with typed wrappers for Angular, Solid, Svelte, and Vue. The API reference below applies to all frameworks.
The interactive, feature-rich sibling of hy-table. Where hy-table is a stateless display table, hy-datagrid owns its own data state — sorting, filtering, selection, column sizing / visibility / pinning, and pagination — on a headless @tanstack/table-core engine, and renders every row through a @tanstack/lit-virtual virtualizer so it stays smooth at 10k+ rows.
Datagrid, table or list?
A datagrid is for records the user filters, selects or pages through. Records only read and compared in columns, sorted at most, are hy-table. Items that are not compared column by column — a feed, people, files, rows to pick from — are hy-list.
It looks identical to hy-table at rest: same emphasis / dividers / striped / hoverable axes, same ambient data-density, same --hy-table-*-derived token surface. It only adds interactive chrome on top.
All @tanstack/* imports live behind internal/datagrid-engine.ts; this host never touches the engine directly.
Scroll performance
DOM-based virtualization (not canvas — real table semantics, ARIA, and CSS theming are all preserved), tuned with the techniques AG Grid, RevoGrid, and Google Sheets use. Together they keep scrolling smooth on 10k+ rows AND remove the blank-on-fast-drag flash entirely. In order of importance:
- Decoupled sticky rendering — the fix for the blank flash on a fast scrollbar-thumb drag. Dragging the thumb is a compositor-thread gesture: Chrome scrolls the layer and paints the destination BEFORE the main-thread scroll event fires (a "partially presented frame"). If rows live in the scrolled coordinate space, the compositor drags them out of view and the destination is blank — unwinnable by rendering harder (AG Grid ships this as open bug #11295). Instead the
<table>is pinned withposition: stickyand an empty.scroll-sizersibling owns the scroll height; rows are positioned in VIEWPORT space (translateY(start - scrollOffset)). A sticky layer is composited correctly during a fast scroll, so a missed frame shows the last-rendered rows still pinned in place (a one-frame content lag) instead of white. This is the Google Sheets / "decoupled rendering" technique. - Per-frame reposition without a render — on scroll, the already-rendered rows are re-pinned to the live offset with a handful of direct transform writes (no framework re-render). The virtualizer only re-renders — adding / removing rows — when the window's start / end index changes, so ordinary wheel scrolling within a stable window costs ~nothing per frame.
- Keyed row recycling (the AG Grid / RevoGrid row-cache technique) — rows are keyed by their stable row id, so on a one-row window shift the rows whose id is unchanged keep their DOM AND their cell content — the browser skips re-laying-out those cells (changing every cell's text forces an ~8ms reflow; a transform-only change is ~0.2ms). Only the one or two rows that entered / left are created / destroyed.
- Stored-size row height — heights come from a size per index, never a per-row DOM measurement. Default: every row is the design system's row height (
--hy-datagrid-control-height-md, falling back to--hy-control-height-md, so rows follow the density mode), read once from an invisible probe and re-read only when that token resolves differently — so the total scroll size is exact (counttimes the height) and the scrollbar maps precisely to the row index. Opt into explicit heights with therowHeightprop (a number, or a(row, index) => pxfunction the virtualizer prefix-sums into exact offsets). - Cached column geometry — width and pinning are resolved once per column per render (not once per cell), so each window-change render stays cheap.
- Overscan — a small buffer (8 rows / side, ≈ AG Grid's
rowBuffer) covers the window-change render's one-frame latency plus sub-frame wheel scrolling. It is NOT the blank-prevention mechanism (decoupling is), so it stays small to keep each render cheap. overflow-anchor: noneon the scroll container prevents scroll-anchoring jumps as the window updates.
For per-row heights, use the rowHeight function (stored-size, math-only); auto-measured (DOM getBoundingClientRect) heights are intentionally not supported, since they reintroduce the per-scroll measurement cost above.
Column virtualization
For very wide grids, set virtualize-columns to additionally window the columns (dual-axis): only the columns in/near the viewport render, via leading/trailing spacer cells, while pinned + selection columns always render. Off by default (narrow grids don't need it; it also disables the fill-to-width behavior).
When to use
- Large sets of records people sort, filter, select and act on: orders, tickets, inventory.
- Acting on several rows at once, with
selectable.
Do
- Offer
quick-filterwhen people look for rows by name. - Sort by the column people need most when the grid opens.
- Pick one surface:
emphasisdraws as onhy-card— a frame (solid,outlined) or a gray body (tinted), never both on one grid.
When not to use
- Parent and child records:
hy-tree. - Items with images and long text: a grid of
hy-card. - Rows people refer to by page number: set
paginatedwithpage-sizeinstead of one long scroll.
Accessibility: your part
- Give every control you place in a
cell-{key}slot or arendercell its own accessible name.
Examples
Full-featured grid — selection, quick filter, export, pagination
<hy-datagrid
label="People"
selectable
quick-filter
exportable
paginated
page-size="50"
></hy-datagrid>
<script>
const grid = document.querySelector('hy-datagrid');
grid.columns = [
{ key: 'name', label: 'Name', width: 180 },
{ key: 'department', label: 'Department', width: 150 },
{ key: 'salary', label: 'Salary', type: 'number', sortType: 'number', align: 'end' },
{ key: 'bio', label: 'Bio', flex: 1 },
];
grid.getRowId = (row) => String(row.id);
grid.data = people; // 10k+ rows stay smooth — every row is virtualized
grid.addEventListener('selection-change', (e) => {
console.log(e.detail.selectedValues);
});
</script>Async loading with skeleton and error states
<hy-datagrid label="Orders"></hy-datagrid>
<script type="module">
const grid = document.querySelector('hy-datagrid');
grid.columns = [
{ key: 'reference', label: 'Reference' },
{ key: 'total', label: 'Total', type: 'number', align: 'end' },
];
grid.loading = true;
try {
const res = await fetch('/api/orders');
grid.data = await res.json();
} catch {
grid.retryable = true; // a `retry` listener re-runs this loader
grid.error = 'Failed to load orders.';
} finally {
grid.loading = false;
}
</script>Selection set from markup or JS — shown at rest, restorable
<hy-datagrid label="People" selectable selected-values="2 5"></hy-datagrid>
<script>
const grid = document.querySelector('hy-datagrid');
grid.selectedValues = ['2', '5']; // row ids (getRowId), no selection-change fired
</script>Variable row heights via the stored-size model (no DOM measurement)
<hy-datagrid label="People"></hy-datagrid>
<script>
const grid = document.querySelector('hy-datagrid');
grid.rowHeight = (row) => (row.status === 'suspended' ? 72 : 44);
</script>API
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
data | — | T[] | [] | Row data. Property-only (arrays can't be reflected to attributes). |
columns | — | DatagridColumn<T>[] | [] | Column definitions. Property-only. |
getRowId | — | (row: T, index: number) => string | undefined | — | Stable row identity. Defaults to row index. Provide this so selection / sizing / measurement survive data re-renders and reorders. |
emphasis | emphasis | 'solid' | 'outlined' | 'tinted' | 'plain' | 'solid' | Visual chrome weight — identical axis to hy-table. |
dividers | dividers | 'none' | 'header' | 'rows' | 'cells' | 'rows' | Cell-divider treatment — identical axis to hy-table. |
verticalAlign | vertical-align | 'top' | 'middle' | 'middle' | Vertical alignment of the content in the body cells — identical axis to hy-table. |
striped | striped | boolean | false | Zebra-stripe rows. |
hoverable | hoverable | boolean | true | Highlight rows on hover. |
label | label | string | '' | aria-label on the grid — required if caption is empty. |
locale | locale | string | undefined | — | BCP-47 locale for the number and date column defaults. Unset, the nearest lang (across shadow roots), else the browser's language — and a later lang change re-renders the cells. |
caption | caption | string | '' | Visible caption above the grid. |
loading | loading | boolean | false | Render skeleton rows instead of data. |
error | error | string | '' | Region load failure message. When set, the grid renders an error empty-state in place of the rows. Distinct from operation failures (use a toast/alert). |
retryable | retryable | boolean | false | The error empty-state offers a Retry button that fires retry. Set it only when a retry listener really re-runs the region's loader; without it the error is message-only (an element cannot see its listeners, so the grid never guesses). React: retryable, or implied by onRetry. |
selectable | selectable | boolean | false | Inject the checkbox-selection column. |
selectionMode | selection-mode | 'single' | 'multiple' | 'multiple' | Selection cardinality. |
quickFilter | quick-filter | boolean | false | Render the global quick-filter input in the toolbar. |
columnMenu | column-menu | boolean | true | Render the column-visibility ("Columns") menu in the toolbar. |
exportable | exportable | boolean | false | Render the CSV-export button in the toolbar. |
hasToolbar | has-toolbar | boolean | false | Server-render hint: content is slotted in toolbar-start or toolbar-end, so the toolbar is drawn in the server's HTML. A server cannot see slotted content, so without it a server-rendered grid draws its toolbar for slotted content one update after hydrating. Set it when the page slots toolbar content and renders on the server; in the browser alone the grid finds the slotted content itself. |
exportFilename | export-filename | string | 'export.csv' | Default file name for exportCsv() / the export button. |
paginated | paginated | boolean | false | Compose hy-pagination in the footer as a display mode. |
pageSize | page-size | number | 25 | Rows per page (paginated mode). |
estimatedRowHeight | estimated-row-height | number | 44 | Fallback row height (px), used only when rowHeight is unset AND the design system's row height (--hy-datagrid-control-height-md → --hy-control-height-md) does not resolve — server rendering, no theme loaded, or before first layout. |
rowHeight | row-height | number | DatagridRowHeight<T> | undefined | — | Opt into a specific row height. A number sets a fixed height (px); a (row, index) => number function sets a per-row height (the stored-size model — heights are math, never DOM-measured). When unset, rows take the design system's row height (--hy-datagrid-control-height-md, falling back to --hy-control-height-md), so they follow the density mode. The function form is property-only (can't be an attribute); the number form also accepts the row-height attribute. |
virtualizeColumns | virtualize-columns | boolean | false | Opt into column (horizontal / dual-axis) virtualization for very wide grids: only the columns in (and near) the viewport render, via leading/trailing spacer cells. Pinned and selection columns always render (they stay sticky); the rest are windowed. Off by default — narrow grids don't need it and it disables the fill-to-width behavior (columns keep their exact widths and the grid scrolls). |
selectedValues | selected-values | string[] | [] | The selected rows, as row ids (getRowId, else the row index as a string) — the same values selection-change reports in selectedValues. Settable from JS or from markup (selected-values, ids separated by spaces or commas) to show a selection at rest or restore one; kept in sync as the user selects. Setting it fires no selection-change (only user selection does). In selection-mode="single" only the first id is kept. Needs selectable. |
filterPlaceholder | filter-placeholder | string | 'Search…' | Placeholder for the global quick-filter input. |
emptyHeading | empty-heading | string | 'No data' | Fallback heading when there is no data and no filter is active. |
emptyDescription | empty-description | string | '' | Fallback description for the empty (no-data) state. |
strings | — | Partial<DatagridStrings> | {} | The built-in strings — the toolbar, selection and resize names, the no-results and load-error states, and every string of the paginated footer (PaginationStrings). Only the keys set replace the English defaults (DEFAULT_DATAGRID_STRINGS); a template keeps its {placeholders}. filterPlaceholder, emptyHeading and emptyDescription stay their own properties. Use for internationalization. |
Events
| Event | Detail | Description |
|---|---|---|
sort | — | TableSortDetail — a sortable header was activated (multi-sort emits the primary key/direction). |
selection-change | — | HySelectionDetail — the row-selection set committed. Detail: { selectedValues, reason }. |
column-resize | — | Detail: DatagridColumnResizeDetail — a resize drag committed. |
column-visibility-change | — | Detail: DatagridVisibilityDetail — a visibility-menu toggle. |
page-change | — | Detail: DatagridPageDetail — relayed from the composed hy-pagination. |
filter-change | — | Detail: DatagridFilterDetail — quick-filter / per-column filter changed. |
retry | — | The error empty-state's Retry button was activated (shown only with retryable; wire onretry to re-run the region's loader). |
Slots
| Slot | Description |
|---|---|
empty | The no-data state: open content, usually an hy-empty-state with the action that adds the first row. The built-in state (empty-heading, empty-description) shows when nothing is slotted. Never shown for no-results, which the grid formats from its strings and offers Clear filters. |
toolbar-start | Extra leading toolbar content (before the quick filter). |
toolbar-end | Extra trailing toolbar content (after the column / export controls). |
cell-{key} | Static override for a column's body cell (mirrors hy-table). |
header-{key} | Static override for a column's header (mirrors hy-table). |
CSS Parts
| Part | Description |
|---|---|
base | Outer scroll-container wrapper (the virtualizer scroll element). |
caption | The caption rendered above the grid. |
toolbar | The toolbar above the grid. |
table | The grid-display <table>. |
head | <thead>. |
header-cell | <th>. |
sort-button | <button> inside a sortable <th>. |
body | <tbody>. |
row | A body <tr>. |
cell | A body <td>. |
footer | The footer (pagination) region. |
CSS Custom Properties
The documented hooks. Every override hook the component reads — one per token it paints — is listed in @whitespaceux/harmony/theme-api.json, and @whitespaceux/harmony/component-bindings.json (the Harmony MCP get-bindings tool) says which part and state each one paints.
| Property | Default | Description |
|---|---|---|
--hy-datagrid-border-strong-rest | var(--hy-border-strong-rest) | Column resize handle color. |
--hy-datagrid-focus-ring-color | var(--hy-focus-ring-color) | Resize handle color while dragging. |
--hy-datagrid-background-neutral-selected | var(--hy-background-neutral-selected) | Selected-row fill. |
--hy-datagrid-shadow-elevation-2 | var(--hy-shadow-elevation-2) | Sticky-column separation shadow. |
--hy-datagrid-control-height-md | var(--hy-control-height-md) | Default body-row height (falls back to the density-aware control height). An explicit rowHeight wins. |
--hy-datagrid-header-background-surface-base | var(--hy-background-surface-base) | Header row background over a tinted body. |
--hy-datagrid-header-label-lg-font-family | var(--hy-label-lg-font-family) | Font family of the column headers (Label LG unless set). |
--hy-datagrid-header-label-lg-font-size | var(--hy-label-lg-font-size) | Font size of the column headers. |
--hy-datagrid-header-label-lg-font-weight | var(--hy-label-lg-font-weight) | Font weight of the column headers. |
--hy-datagrid-header-label-lg-letter-spacing | var(--hy-label-lg-letter-spacing) | Letter spacing of the column headers. |
--hy-datagrid-header-label-lg-line-height | var(--hy-label-lg-line-height) | Line height of the column headers. |
--hy-datagrid-header-text-transform | none | Case of the column headers (none unless set; uppercase shows them in capitals). Display only — the header text stays as written. |
Methods
exportCsv()
Export the current (filtered + sorted) rows to a downloaded CSV file.
clearSelection()
Clear the current row selection.