Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { VirtualGridCell, VirtualGridData } from "./virtual-grid.contract"

export interface VirtualGridRange {
  rowStart: number
  rowEnd: number
  colStart: number
  colEnd: number
  /** exactly how many gridcells this window puts in the DOM — (rows × cols) of the window */
  cellCount: number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/virtual-grid.json

Prompt

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 payloadrowCount × columnCount is the addressable space, cells is 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 scrollTop would 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 clamping0, negative, NaN and Infinity are 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.

On This Page