Examples

One grid, drawn twice from the same frame — once as DOM, once as canvas.

Both panes below share a single createGrid instance over 10,000 resources and two weeks at 15-minute resolution — 10,000 × 1,345 cells, 3,000 items. Neither renderer knows about the other; they only read the frame the engine hands them, which is why scrolling one moves the other.

That is the whole point of a headless engine: the frame is plain numbers, so two renderers with nothing in common can draw the same state.

10,001 × 1,345 cellsrows in frame 1columns in frame 1items in frame 0DOM nodes 0scroll 672, 0canvas hover

DOM

Absolutely positioned inside a box the size of frame.total, scrolled natively by the browser. Pinned rows and columns are the one exception: they are translated by the current scroll so they stay put.

Only what the frame lists is in the document. At this size that is a few hundred nodes against 13.45 million cells, and the count above changes as you scroll.

Canvas

The same frame drawn with CanvasRenderingContext2D. There is no native scrolling here: wheel events move grid.setScroll, which the pane above follows through its binder — scroll either one and both track.

A canvas has no scroll container, so it subtracts frame.scroll once and draws everything in screen space. Pinned entries are the exception in the other direction: on the pinned axis their start is already a screen coordinate, so it is used unchanged.

function draw(ctx, frame, viewport, colors) {
  const { scroll } = frame;
  ctx.clearRect(0, 0, viewport.width, viewport.height);

  const bodyRows = frame.rows.filter((r) => !r.pinned);
  const bodyColumns = frame.columns.filter((c) => !c.pinned);
  const pinnedRows = frame.rows.filter((r) => r.pinned);
  const pinnedColumns = frame.columns.filter((c) => c.pinned);

  // Gridlines. Half-pixel centres keep a 1px line off the seam between two
  // device pixels.
  ctx.strokeStyle = colors.line;
  for (const row of bodyRows) {
    const y = Math.round(row.start + row.size - scroll.y) + 0.5;
    ctx.beginPath();
    ctx.moveTo(0, y);
    ctx.lineTo(viewport.width, y);
    ctx.stroke();
  }
  for (const col of bodyColumns) {
    const x = Math.round(col.start - scroll.x) + 0.5;
    ctx.beginPath();
    ctx.moveTo(x, 0);
    ctx.lineTo(x, viewport.height);
    ctx.stroke();
  }

  // Items. `x` and `y` are content space and already account for lanes.
  for (const it of frame.items) {
    ctx.beginPath();
    ctx.roundRect(it.x - scroll.x, it.y - scroll.y, it.width, it.height, 4);
    ctx.fill();
  }

  // Pinned label column and header row: `start` is already screen space on
  // the pinned axis, only the other axis is scrolled.
  for (const col of pinnedColumns) {
    for (const row of bodyRows) {
      ctx.fillRect(col.start, row.start - scroll.y, col.size, row.size);
    }
  }
  for (const row of pinnedRows) {
    for (const col of bodyColumns) {
      ctx.fillRect(col.start - scroll.x, row.start, col.size, row.size);
    }
  }
}

Wheel events are the only input the canvas needs, and hit-testing is one call — viewportPoint converts a pointer event into the space cellAt expects:

const onWheel = (e: WheelEvent) => {
  const max = grid.maxScroll();
  const { scroll } = grid.getState();
  grid.setScroll({
    x: Math.min(Math.max(scroll.x + e.deltaX, 0), max.x),
    y: Math.min(Math.max(scroll.y + e.deltaY, 0), max.y),
  });
};

const onPointerMove = (e: PointerEvent) => {
  const hit = grid.cellAt(viewportPoint(canvas, e));
  // { row, column } or null outside the content
};

The full source of both renderers, the shared data and the theme-token palette is in apps/docs/components/examples.