Grid

Grid Overview

Two paths for using the engine: the <Pretable> drop-in or the usePretable hooks for custom rendering.

Pretable's React engine ships two consumer paths. Pick the one that matches what you're building. Need to render your own UI — a non-table layout or a non-React renderer? See the headless engine.

The grid below is Path 1 end to end — <Pretable> given rows, columns, getRowId, and ariaLabel, nothing else:

The engine, working

Five customer accounts rendered with the Pretable drop-in component — the shortest path through the engine, and the shape both consumer paths on this page build on.

.md

The two paths

Path 1: <Pretable> drop-in

The grid above is the whole recipe:

tsx
import { Pretable } from "@pretable/react";
 
<Pretable
  rows={rows}
  columns={columns}
  getRowId={(row) => row.id}
  ariaLabel="Customer accounts"
/>;

A 4-prop component that renders a basic grid. Internally it wraps the engine with sensible defaults — plain value cells, 320px viewport. Use this when you want a working grid with one import and don't need sort UI, filter UI, selection state in your component tree, or custom cell rendering.

What <Pretable> does NOT support out of the box:

  • Sort/filter UI (the engine handles state, but <Pretable> doesn't render the controls)
  • Selection state controlled from your component
  • Custom cell rendering
  • Pinned columns (controlled rendering)
  • Density-prop overrides (CSS-driven only, via [data-density] on <html> or on a wrapper element)

If you need any of the above, jump to Path 2. See <Pretable> component for the full props reference.

Path 2: usePretable hook (custom rendering)

tsx
import { usePretable } from "@pretable/react";
 
function MyGrid({ rows, columns }) {
  const { grid, snapshot, renderSnapshot, telemetry } = usePretable({
    columns,
    rows,
    getRowId: (row) => row.id,
    viewportHeight: 480,
  });
 
  // Render your own JSX, applying [data-pretable-*] attributes for styling
  return; /* ... */
}

The hook returns an indexed render snapshot plus UI actions. Data and query state live in the long-lived row model; focus, selection, editing, viewport, and visual column layout live in the UI grid. In practice you reach for <PretableSurface> first, which is built on this hook and already renders the markup — Path 2's raw form is for a design-system wrapper that needs to own every element itself.

Use this when:

  • You need a real production grid with sort/filter/selection/focus
  • You want custom cell rendering (different per-column React components)
  • You're building a wrapper around the engine for your design system

See <PretableSurface> component for the configuration hub, and Custom rendering for cell/header render hooks with working examples.

The data-attribute contract

Both paths emit the same [data-pretable-*] data attributes on grid elements. @pretable/ui/grid.css styles those attributes. The contract:

AttributeElementPurpose
[data-pretable-scroll-viewport]the scrollable containerOuter chrome, focus ring, fixed height
[data-pretable-scroll-content]the absolute-positioned content layerTotal scroll height for virtualization
[data-pretable-header-row]the sticky header rowHeader background, bottom border
[data-pretable-header-cell]each header column buttonHeader text, sort indicator
[data-pretable-header-overlays]zero-width anchor on each column's trailing edgeStructure only — grid.css gives it no skin. The resize strip and filter funnel live inside it (not inside the header cell, which is a <button>), positioned by counting back from that edge
[data-pretable-resize-handle]the 4px column-resize stripHandle color, idle and on hover/drag
[data-pretable-filter-funnel]the filter button in each headerFunnel color, and revealing it on header hover
[data-pretable-row]each body rowRow positioning (position: absolute + top)
[data-pretable-cell]each body cellCell background, padding, font, gridlines
[data-pretable-wrap]each body cell (modifier)"true" on cells with column.wrap; coexists with [data-pretable-cell]
[data-pretable-pinned]pinned cells (header + body)"left" or "right" — sticky positioning against that edge, distinct background
[data-pretable-selected]="true"selected cellsSelection background and text color
[data-pretable-focused]="true"focused row + cellFocus outline; mirrored on row and cell so CSS can target either
[data-pretable-hydrated]the scrollable container"false" in server-rendered HTML, "true" once React has hydrated the grid
[data-pretable-row-select-cell]the checkbox column's body cells (modifier)"true" on the synthetic row-select column only; coexists with [data-pretable-cell]
[data-pretable-row-select-header]the checkbox column's header (modifier)Present on the synthetic row-select column's header only; coexists with [data-pretable-header-cell]

Path 1 (<Pretable>) emits these automatically. Path 2 (custom rendering) requires you to apply them yourself — @pretable/ui/grid.css only styles elements that match these selectors. See Custom rendering for examples.

Two of those header hooks are pointer-conditional in @pretable/ui. Under @media (pointer: coarse) the resize strip is display: none — it generates no boxes, so a skin for it paints nothing on a phone — and the filter funnel's hover reveal is replaced by opacity: 1 at rest, because a coarse pointer never hovers. The same goes for the column menu. If you are writing your own skin, mirror both rules or a touch user gets a 4px strip they cannot hit and a funnel they cannot see. See Column layout § Resizing is a pointer affordance and Filtering § The funnel on touch.

The synthetic row-select column matches [data-pretable-cell] and [data-pretable-header-cell] like any other column, and being left-pinned it is the first match for both. Exclude it with :not([data-pretable-row-select-cell]) — note that [data-pretable-column-id] does not exclude it, because its cells do carry an id (__pretable_row_select__); only its header omits one. See telling it apart in the DOM.

Knowing when a server-rendered grid is interactive

<Pretable> and <PretableSurface> render on the server. Everything that makes the grid feel interactive — sort buttons in the header, filter funnels, row-select checkboxes, column resize handles — is in that server HTML, so it paints, sits under the cursor, and takes clicks well before React has attached a single event handler. A click in that window is accepted by the browser and goes nowhere: no sort, no menu, no feedback. On a fast connection the window is invisible; on a slow one it is long enough for a user to click twice and conclude the page is broken.

The grid publishes that state on its root:

html
<!-- server HTML: painted, but every control is inert -->
<div data-pretable-scroll-viewport role="grid" data-pretable-hydrated="false">
  ...
</div>
 
<!-- after hydration: handlers attached -->
<div data-pretable-scroll-viewport role="grid" data-pretable-hydrated="true">
  ...
</div>

The flag comes from a useSyncExternalStore gate that reports false on the server and throughout the hydration render, then true on the first client-only render — the same render that attaches the handlers. That is why it is safe: the server string and React's hydration pass agree, so there is no hydration mismatch, and the attribute only flips once the controls really are live.

Use it to make the pre-interactive state honest rather than silent. For example, dim the controls until they work:

css
[data-pretable-hydrated="false"] [data-pretable-filter-funnel],
[data-pretable-hydrated="false"] [data-pretable-header-cell] {
  opacity: 0.6;
  cursor: progress;
}

It is equally the right thing for an end-to-end test to wait on. [data-pretable-scroll-viewport] being visible does not discriminate — it is in the prerendered markup alongside the dead controls — so a test that gates on visibility and then clicks a funnel is racing hydration. Gate on data-pretable-hydrated="true" instead.

Grids that only ever mount on the client (lazily, on scroll into view) report "true" on their first render, so the same check is correct there too.

Engine reads three CSS variables in JS

For row virtualization, the engine needs heights as numbers — to compute row top positions, the body viewport height, and how much of the viewport the drag-to-group strip takes off the top. It reads --pretable-row-height, --pretable-header-height, and --pretable-group-panel-height from the grid element's own computed style, and watches that element and every ancestor up to <html> for attribute changes. The tokens inherit, so this resolves whichever data-density the grid actually paints under — the root's, or a wrapper's. Flip the attribute at either level and the engine re-renders with new heights automatically; you do not wire anything.

--pretable-group-panel-height is read only while the group panel is enabled. With the panel off the engine skips the lookup and the strip contributes zero height, so every other height is bit-for-bit what it would be without the feature.

The other 47 tokens in the theming contract are CSS-only — they style the data-attribute selectors but don't enter JavaScript.

getDensityHeights from @pretable/ui reads the first two of the three into your own code — row and header height, not the group panel. See Density helpers for its fallbacks and SSR behaviour.

What's not yet documented

The engine has more capabilities than this section covers:

  • Streaming and transactions — ordinary React updates use the rows prop; high-frequency producers explicitly own a row model and connect the streaming adapter.
  • Per-row measured heights — pass measuredHeights: Record<string, number> to usePretable for content-aware row sizing. The bench's pretable-adapter.tsx shows this pattern.

These ship as part of @pretable/react today; full doc coverage lands in subsequent releases.

Selection, keyboard, clipboard

The engine ships full cell-range selection (Excel/Sheets semantics with shift-extend, drag, Cmd/Ctrl+click), the ARIA grid keyboard pattern, and Cmd/Ctrl+C copy with TSV defaults — all wired by <Pretable> and <PretableSurface> out of the box. See Selection, Keyboard, and Clipboard.

Where to go next