Virtual Grid
Two-dimensional windowing — only the cells near the viewport exist in the DOM, with fixed or per-track measured row heights and column widths, and no virtualization library.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/virtual-grid.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "VirtualGrid" component that windows a
matrix on both axes, with no virtualization library — hand-roll the maths (no
@tanstack/react-virtual, no react-window).
Contract
- zod schema (virtual-grid.contract.ts) as the single source of truth:
cell: { id: string; row: int >= 0; col: int >= 0; label: string;
intensity?: number 0..1 }
data: { status: "loading" | "empty" | "error" | "ready"; title: string;
rowCount: int >= 0; columnCount: int >= 0; cells: cell[] }
refine: status "ready" requires rowCount > 0 and columnCount > 0; every cell
must sit inside the declared extent (a cell outside it can never be scrolled
to, so it is a data bug, not a rendering one). `cells` is SPARSE — it carries
only the coordinates that have a reading, so a 600 × 24 grid can ship 9,760
cells instead of 14,400.
- Props on top of the contract: rowHeight?: number | ((row) => number) (default
44), columnWidth?: number | ((col) => number) (default 128), overscan?
(default 2), height?: number | string (default 360), renderCell?: (cell |
null, { row, col }) => ReactNode, onRangeChange?: ({ rowStart, rowEnd,
colStart, colEnd, cellCount }) => void, onRetry?, plus forwardRef<HTMLDivElement>
and the rest of the native div props spread on the card root. A number means a
uniform track; a function means "estimate now, measure later" for that axis —
the two axes choose independently.
- All four states are first-class branches, not an afterthought `&&`: a pulsing
tile skeleton (aria-hidden, motion-reduce:animate-none, plus an sr-only
role="status"), an empty branch that prints the extent it resolved to, an
error branch whose retry button only exists when onRetry is passed, and ready.
status "ready" with a degenerate extent falls into the empty branch.
Behavior
- Axis layout: for a uniform track, offsets are arithmetic (index * size) and
nothing is allocated, so a million rows cost no memory. For a measured axis,
build a prefix-sum array of (measured ?? clamped estimate) sizes, memoized on
[count, sizeProp, measurements].
- Window per axis: the tracks intersecting [scrollPos, scrollPos + viewport),
widened by overscan and clamped to the extent — binary search on the prefix
sums, plain arithmetic in the uniform case. The rendered cell count is exactly
(rowEnd - rowStart + 1) * (colEnd - colStart + 1) and there is NO cap anywhere
between that number and the render loop: if the window says 1,776 cells, the
DOM has 1,776 cells. onRangeChange reports the same numbers, so a consumer can
assert it.
- Input clamping (this is a component that divides by its inputs, so every one
of them is hostile until proven otherwise): a track size that is not a finite
number > 0 falls back to the default, and any size is floored at 4px — below
that a screenful stops being a grid and becomes a memory hazard. rowCount /
columnCount that are not finite collapse to 0, which lands in the empty
branch instead of spinning the window loop. overscan is floor-clamped into
0..32; a non-finite one falls back to the default. A non-positive or
non-finite `height` falls back too. None of these may hang, and none may paint
a blank box.
- Scrolling: one passive listener added by hand (React's onScroll cannot be
passive), rAF-throttled to at most one window recompute per frame, both
offsets read from the DOM node. requestAnimationFrame is cancelled and the
listener removed on unmount.
- Container size: a ResizeObserver on the scroll viewport drives the viewport
width/height used by the window maths; read the client box synchronously in a
layout effect on mount too, so the first paint is not a one-column flash.
- Measurement: ONE shared ResizeObserver observes every mounted row (block size)
and every mounted cell (inline size). Create it lazily inside the ref callback
— refs are attached during the commit, before any effect runs, so an
effect-created observer misses the whole first screenful, and cached ref
callbacks are never re-attached to fix it up. Measurements only ever grow
(max-so-far): a max is a fixed point, so measure → relayout → measure cannot
oscillate. A new `cells` array identity resets the caches via the
adjust-state-during-render pattern, not an effect.
- Sizing rules that follow from measurement: an unmeasured column leaves its
cells free to size to their content (that IS the measurement) with the track
as a minimum; once measured, every cell in the column is pinned to the track
width, or the column would never line up as a column. Rows keep a floor
instead of a pinned height, so re-wrapped content grows the row for one frame
and the track catches up, rather than being clipped.
- Scroll anchoring (the reason measured mode is usable at all): when a track
above the viewport is measured, everything below it shifts and the same
scrollTop now points at different content. After every commit, capture an
anchor = (first visible track, distance scrolled into it); when the offsets
change identity, re-solve the anchor against the new offsets and assign
scrollTop/scrollLeft to match. Set overflow-anchor: none so the browser's own
scroll anchoring does not fight it. The observable property: scrolling up
200px into never-measured tracks must move the content exactly 200px.
- Keyboard: the viewport is the single tab stop; arrow keys, PageUp/PageDown,
Home/End and Ctrl+Home/Ctrl+End move a focused cell, clamped to the extent.
Focus is expressed with aria-activedescendant, not DOM focus, because the
focused cell can be unmounted at any moment by a mouse scroll — and only set
the attribute while that cell is inside the current window. Scroll-into-view
is computed from the layout (the target may not exist in the DOM yet), by the
minimum delta, with no smooth scrolling.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground on the root, border,
bg-muted for the skeleton, text-muted-foreground for holes and captions. Cell
tint is color-mix(in oklab, var(--chart-2) X%, var(--card)) with X ramped
6→70% by `intensity`; mixing into --card makes the ramp travel away from the
surface in both themes, which is what keeps --foreground legible on every step
(measured 4.79:1 light, 7.48:1 dark at the ceiling).
- Structure: scroll viewport (overflow-auto, both axes) > sizer sized to the
full virtual width/height with role="presentation" — an unlabelled div between
a grid and its rows breaks the ownership chain and assistive tech announces an
empty grid > absolutely positioned rows, each a flex row whose padding-left is
the offset of the first mounted column (one indent instead of positioning
every cell).
- Cell padding lives on the CONTENT, never on the cell box: horizontal padding
is a floor on a box's width, so px-2 on the cell would render a 4px column as
17px and every cell would spill into its neighbour's track while the maths
still said 4.
- Accessibility, and its honest cost: role="grid" + aria-rowcount / aria-colcount
carrying the TOTALS, role="row" + aria-rowindex and role="gridcell" +
aria-colindex carrying ABSOLUTE 1-based positions, so a screen reader is told
where a row sits in 600 even though only a dozen exist. An sr-only paragraph
(aria-describedby) states the totals and says outright that only cells near
the viewport exist and that the arrow keys are how you reach the rest. Be
upfront in the docs: virtualization is hostile to screen readers by
construction. Assistive tech cannot skim what is not in the DOM, "read all"
will only read the mounted window, and browser find-in-page will not find text
that has not been scrolled into existence. If the dataset is small enough to
render whole, render it whole.
- No decorative motion beyond the skeleton pulse, so prefers-reduced-motion has
nothing else to gate; keyboard scroll-into-view is an instant jump on purpose.
Customization levers
- rowHeight / columnWidth: numbers for a uniform grid (cheapest, exact, scales
to a million tracks); functions to opt an axis into measurement — pass the
closest estimate you have, and pass a stable function identity (module scope
or useCallback) so the layout memo keeps hitting.
- overscan: raise to 6–12 for fast trackpads or expensive cells that need a head
start; drop to 0 for the smallest possible DOM. It is clamped to 32, and the
mounted count grows quadratically here — +overscan costs (rows + 2n) × (cols +
2n) cells, not 2n.
- renderCell: the main lever. The default cell prints `label` with a tint from
`intensity`; swap in an <img> for an image wall, a status pill for a sparse
spreadsheet, or a skeleton for cells you have not fetched yet. You own the
padding and any truncation inside it.
- height: a number for a bounded panel, or any CSS length ("60vh", "100%") to
fill a flexible layout — the ResizeObserver keeps the window correct either
way.
- Tint: re-point the color-mix at var(--chart-1..5) or var(--primary), or drop
it entirely and let renderCell own the whole visual.
- Extent vs payload: rowCount/columnCount describe the addressable space and
`cells` only the coordinates that exist, so you can declare a 5,000-row extent
and stream cells in as ranges arrive.Concepts
- Two-axis windowing — rows and columns are the same computation run twice; a cell exists in the DOM only if both of its tracks are inside their axis window, which is why a 14,400-cell matrix ships ~130 nodes.
- Extent vs payload —
rowCount × columnCountis the addressable space,cellsis the sparse set of coordinates that actually have a reading; holes still render a cell box, so geometry never depends on how full the matrix is. - Overscan is quadratic — one extra ring of tracks costs
(rows + 2) × (cols + 2)cells, not two rows: on a 2D grid the cheap-looking knob is the expensive one. - Estimate → measure → pin — a measured axis starts from your estimate, learns the real size from a shared ResizeObserver, and only ever grows (max-so-far), which is what makes measure → relayout → measure terminate instead of oscillate.
- Scroll anchoring — measuring a track above the viewport moves everything below it, so the same
scrollTopwould silently mean different content; the component re-solves "first visible track + how far into it" against the new offsets and corrects the scroll position in the same frame. - Hostile-input clamping —
0, negative,NaNandInfinityare the normal contents of an unvalidated API response: sizes fall back and are floored, extents collapse to the empty state, overscan is clamped — the window loop is never allowed to be infinite. - aria-activedescendant over DOM focus — the focused cell can be unmounted by any mouse scroll; keeping DOM focus on the viewport and naming the cell by id survives that, where
element.focus()would drop focus to the body mid-scroll.
Lazy Render
Defers mounting a subtree until it scrolls into view or the browser goes idle, holding its space with an explicitly reserved placeholder height.
Sticky Stack
Multi-level sticky section headers that park and shove one another, with every parking line accumulated from the measured heights of the headers above it.