Skip to content

HyDatagrid

Usage examples on this page are written for Lit / plain HTML (<hy-datagrid>). The same component ships as HyDatagrid in @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.

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 with position: sticky and an empty .scroll-sizer sibling 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 DOM measurement. Default: every row is estimatedRowHeight px, so the total scroll size is exact (count times the height) and the scrollbar maps precisely to the row index. Opt into variable heights with the rowHeight prop (a number, or a (row, index) => px function the virtualizer prefix-sums into exact offsets) — still no getBoundingClientRect / ResizeObserver.
  • 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: none on 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 behaviour).

Examples

javascript
<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

javascript
<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.error = 'Failed to load orders.';
} finally {
grid.loading = false;
}
</script>

Variable row heights via the stored-size model (no DOM measurement)

javascript
<hy-datagrid label="People"></hy-datagrid>
<script>
const grid = document.querySelector('hy-datagrid');
grid.rowHeight = (row) => (row.status === 'suspended' ? 72 : 44);
</script>

API

Properties

PropertyAttributeTypeDefaultDescription
dataT[][]Row data. Property-only (arrays can't be reflected to attributes).
columnsDatagridColumn<T>[][]Column definitions. Property-only.
getRowId(row: T, index: number) => string | undefinedStable row identity. Defaults to row index. Provide this so selection / sizing / measurement survive data re-renders and reorders.
emphasisemphasis'solid' | 'outlined' | 'tinted' | 'plain''solid'Visual chrome weight — identical axis to hy-table.
dividersdividers'none' | 'rows' | 'cells''rows'Cell-divider treatment — identical axis to hy-table.
stripedstripedbooleanfalseZebra-stripe rows.
hoverablehoverablebooleantrueHighlight rows on hover.
labellabelstring''aria-label on the grid — required if caption is empty.
captioncaptionstring''Visible caption above the grid.
loadingloadingbooleanfalseRender skeleton rows instead of data.
errorerrorstring''Region load failure message. When set, the grid renders an error empty-state with a retry affordance (wire onretry via the retry event). Distinct from operation failures (use a toast/alert). See .claude/rules/data-region-states.md.
selectableselectablebooleanfalseInject the checkbox-selection column.
selectionModeselection-mode'single' | 'multiple''multiple'Selection cardinality.
quickFilterquick-filterbooleanfalseRender the global quick-filter input in the toolbar.
columnMenucolumn-menubooleantrueRender the column-visibility ("Columns") menu in the toolbar.
exportableexportablebooleanfalseRender the CSV-export button in the toolbar.
exportFilenameexport-filenamestring'export.csv'Default file name for exportCsv() / the export button.
paginatedpaginatedbooleanfalseCompose hy-pagination in the footer as a display mode.
pageSizepage-sizenumber25Rows per page (paginated mode).
estimatedRowHeightestimated-row-heightnumber44Fixed row height (px) used by the virtualizer when rowHeight is unset.
rowHeightrow-heightnumber | DatagridRowHeight<T> | undefinedOpt 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 use estimatedRowHeight. The function form is property-only (can't be an attribute); the number form also accepts the row-height attribute.
virtualizeColumnsvirtualize-columnsbooleanfalseOpt 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 behaviour (columns keep their exact widths and the grid scrolls).
filterPlaceholderfilter-placeholderstring'Search…'Placeholder for the global quick-filter input.
emptyHeadingempty-headingstring'No data'Fallback heading when there is no data and no filter is active.
emptyDescriptionempty-descriptionstring''Fallback description for the empty (no-data) state.

Events

EventDetailDescription
sortTableSortDetail — a sortable header was activated (multi-sort emits the primary key/direction).
selection-changeHySelectionDetail — the row-selection set committed. Detail: { selectedValues, reason }.
column-resizeDetail: DatagridColumnResizeDetail — a resize drag committed.
column-visibility-changeDetail: DatagridVisibilityDetail — a visibility-menu toggle.
page-changeDetail: DatagridPageDetail — relayed from the composed hy-pagination.
filter-changeDetail: DatagridFilterDetail — quick-filter / per-column filter changed.
retryThe error empty-state's retry affordance was activated (wire onretry to re-run the region's loader).

Slots

SlotDescription
toolbar-startExtra leading toolbar content (before the quick filter).
toolbar-endExtra 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

PartDescription
baseOuter scroll-container wrapper (the virtualizer scroll element).
captionThe caption rendered above the grid.
toolbarThe toolbar above the grid.
tableThe grid-display <table>.
head<thead>.
header-cell<th>.
sort-button<button> inside a sortable <th>.
body<tbody>.
rowA body <tr>.
cellA body <td>.
footerThe footer (pagination) region.

CSS Custom Properties

PropertyDescription
--hy-datagrid-resize-handle-colorColumn resize handle colour.
--hy-datagrid-resize-handle-activeResize handle colour while dragging.
--hy-datagrid-row-selected-backgroundSelected-row fill.
--hy-datagrid-pinned-shadowSticky-column separation shadow.

Methods

exportCsv()

Export the current (filtered + sorted) rows to a downloaded CSV file.

clearSelection()

Clear the current row selection.

Built with Lit. Documented with VitePress.