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.

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 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 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 (count times the height) and the scrollbar maps precisely to the row index. Opt into explicit heights with the rowHeight prop (a number, or a (row, index) => px function 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: 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 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-filter when people look for rows by name.
  • Sort by the column people need most when the grid opens.
  • Pick one surface: emphasis draws as on hy-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 paginated with page-size instead of one long scroll.

Accessibility: your part ​

  • Give every control you place in a cell-{key} slot or a render cell its own accessible name.

Examples ​

html
<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 ​

html
<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 ​

html
<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) ​

html
<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
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.
emphasisemphasis'solid' | 'outlined' | 'tinted' | 'plain''solid'Visual chrome weight — identical axis to hy-table.
dividersdividers'none' | 'header' | 'rows' | 'cells''rows'Cell-divider treatment — identical axis to hy-table.
verticalAlignvertical-align'top' | 'middle''middle'Vertical alignment of the content in the body cells — identical axis to hy-table.
stripedstripedbooleanfalseZebra-stripe rows.
hoverablehoverablebooleantrueHighlight rows on hover.
labellabelstring''aria-label on the grid — required if caption is empty.
localelocalestring | 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.
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 in place of the rows. Distinct from operation failures (use a toast/alert).
retryableretryablebooleanfalseThe 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.
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.
hasToolbarhas-toolbarbooleanfalseServer-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.
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-heightnumber44Fallback 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.
rowHeightrow-heightnumber | 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.
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 behavior (columns keep their exact widths and the grid scrolls).
selectedValuesselected-valuesstring[][]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.
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.
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 ​

EventDetailDescription
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 ​

SlotDescription
emptyThe 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-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 ​

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.

PropertyDefaultDescription
--hy-datagrid-border-strong-restvar(--hy-border-strong-rest)Column resize handle color.
--hy-datagrid-focus-ring-colorvar(--hy-focus-ring-color)Resize handle color while dragging.
--hy-datagrid-background-neutral-selectedvar(--hy-background-neutral-selected)Selected-row fill.
--hy-datagrid-shadow-elevation-2var(--hy-shadow-elevation-2)Sticky-column separation shadow.
--hy-datagrid-control-height-mdvar(--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-basevar(--hy-background-surface-base)Header row background over a tinted body.
--hy-datagrid-header-label-lg-font-familyvar(--hy-label-lg-font-family)Font family of the column headers (Label LG unless set).
--hy-datagrid-header-label-lg-font-sizevar(--hy-label-lg-font-size)Font size of the column headers.
--hy-datagrid-header-label-lg-font-weightvar(--hy-label-lg-font-weight)Font weight of the column headers.
--hy-datagrid-header-label-lg-letter-spacingvar(--hy-label-lg-letter-spacing)Letter spacing of the column headers.
--hy-datagrid-header-label-lg-line-heightvar(--hy-label-lg-line-height)Line height of the column headers.
--hy-datagrid-header-text-transformnoneCase 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.

Built with Lit. Documented with VitePress.