schedgrid/react

useGrid, useFrame and useScrollContainer wrap the engine in hooks.

import { useFrame, useGrid, useScrollContainer } from 'schedgrid/react';

The hooks add nothing to the engine. They only tie its lifetime and its subscription to a component.

All three take the item type as a parameter, so frame.items[n].item comes back as your own type rather than Item.

useGrid(options)

function useGrid<T extends Item = Item>(options: GridOptions<T>): Grid<T>;

Creates a grid once for the lifetime of the component. Later option changes go through the grid's setters (setItems, setColumns, ...); the options object is only read on the first render.

const grid = useGrid<Event>({ rows, columns, items });

useEffect(() => {
  grid.setItems(items);
}, [grid, items]);

GridOptions is documented on the core page.

useFrame(grid)

function useFrame<T extends Item>(grid: Grid<T>): Frame<T>;

Subscribes the component to the grid and returns the current frame. The frame object is memoised by the engine, so React bails out of re-renders whenever nothing relevant changed. It is a thin wrapper over useSyncExternalStore.

Note that useFrame has no default for T, unlike useGrid — it takes the type from the grid you hand it, so you never write it out.

useScrollContainer(grid)

function useScrollContainer<
  T extends Item,
  E extends HTMLElement = HTMLElement,
>(grid: Grid<T>): (element: E | null) => void;

A callback ref for the scroll container. Attach it to the element with overflow: auto and the grid receives its viewport; the element takes the grid's scroll offset, so a scroll passed to useGrid still holds once the element binds. It rebinds if the element or the grid changes and unbinds on unmount.

const ref = useScrollContainer<Event, HTMLDivElement>(grid);
return (
  <div ref={ref} style={{ overflow: 'auto' }}>

  </div>
);

Hit-testing

There is no hook for this: grid.cellAt takes a viewport-space point, and viewportPoint turns a React pointer event into one. e.currentTarget is the scroll container, which is the element the point must be relative to.

import { viewportPoint } from 'schedgrid/dom';

<div
  ref={ref}
  onPointerMove={(e) => {
    const cell = grid.cellAt(viewportPoint(e.currentTarget, e));
    setHover(cell ? `${cell.row}, ${cell.column}` : '–');
  }}
  onPointerLeave={() => setHover('–')}
>

Pinned bands are resolved before scroll is applied, so a pointer over the header row reports the header row wherever the user has scrolled.

A complete renderer

Two hundred resources by one day of 15-minute slots, with a pinned header row and a pinned label column. Scroll it, and hover to hit-test.

201 × 97 cellsrows in frame 1columns in frame 1items in frame 0cell

Everything is absolutely positioned in content space inside a box the size of frame.total, and the browser scrolls it natively. Pinned rows and columns are the one exception: they are translated by the current scroll so they stay put.

The setup first — the axes, the item type and the data. Column 0 is a pinned label column and row 0 a pinned header, so slot i lives in column i + 1 and resource n in row n + 1:

import { useState } from 'react';

import { viewportPoint } from 'schedgrid/dom';
import { useFrame, useGrid, useScrollContainer } from 'schedgrid/react';
import { timeAxis } from 'schedgrid/time';
import type { Item } from 'schedgrid/core';

const MIN = 60_000;
const RESOURCES = 200;

const dayStart = Date.UTC(2026, 8, 7);
const time = timeAxis({
  start: dayStart,
  end: dayStart + 24 * 60 * MIN,
  slot: 15 * MIN,
  size: 20,
});

const rows = {
  count: RESOURCES + 1,
  size: (i: number) => (i === 0 ? 32 : 36),
  pinned: 1,
};

const columns = {
  count: time.count + 1,
  size: (i: number) => (i === 0 ? 120 : 20),
  pinned: 1,
};

interface Event extends Item {
  title: string;
}

const items: Event[] = [
  {
    id: 'standup',
    rows: [3, 4],
    // `+ 1` for the label column; `toRange` returns fractional indices.
    columns: time
      .toRange(dayStart + 9 * 60 * MIN, dayStart + 10 * 60 * MIN)
      .map((c) => c + 1) as [number, number],
    title: 'Standup',
  },
];

/** A label on the hour, nothing in between. */
function columnLabel(column: number): string | null {
  const date = time.fromIndex(column - 1);
  if (date.getUTCMinutes() !== 0) return null;
  return `${String(date.getUTCHours()).padStart(2, '0')}:00`;
}

Then the component. Styles are inline here to keep the shape visible; the real one uses classes:

export function ScheduleDemo() {
  const grid = useGrid<Event>({ rows, columns, items });
  const frame = useFrame(grid);
  const ref = useScrollContainer<Event, HTMLDivElement>(grid);
  const [hover, setHover] = useState<string>('–');

  const pinnedRows = frame.rows.filter((r) => r.pinned);
  const bodyRows = frame.rows.filter((r) => !r.pinned);
  const pinnedColumns = frame.columns.filter((c) => c.pinned);
  const bodyColumns = frame.columns.filter((c) => !c.pinned);
  const { scroll, total } = frame;

  return (
    <div
      ref={ref}
      style={{ height: 320, overflow: 'auto' }}
      onPointerMove={(e) => {
        const cell = grid.cellAt(viewportPoint(e.currentTarget, e));
        setHover(cell ? `${cell.row}, ${cell.column}` : '–');
      }}
      onPointerLeave={() => setHover('–')}
    >
      <div
        style={{
          position: 'relative',
          width: total.width,
          height: total.height,
        }}
      >
        {frame.items.map((it) => (
          <div
            key={it.id}
            style={{
              position: 'absolute',
              transform: `translate(${it.x}px, ${it.y}px)`,
              width: it.width,
              height: it.height,
            }}
          >
            {it.item.title}
          </div>
        ))}

        {/* Pinned label column: follows horizontal scroll. */}
        {pinnedColumns.map((col) =>
          bodyRows.map((row) => (
            <div
              key={`${col.index}:${row.index}`}
              style={{
                position: 'absolute',
                left: scroll.x + col.start,
                top: row.start,
                width: col.size,
                height: row.size,
              }}
            >
              Resource {row.index}
            </div>
          )),
        )}

        {/* Pinned header row: follows vertical scroll. */}
        {pinnedRows.map((row) =>
          bodyColumns.map((col) => (
            <div
              key={`${row.index}:${col.index}`}
              style={{
                position: 'absolute',
                left: col.start,
                top: scroll.y + row.start,
                width: col.size,
                height: row.size,
              }}
            >
              {columnLabel(col.index)}
            </div>
          )),
        )}
      </div>
    </div>
  );
}

That is the whole renderer. The version running above adds the corner cell, 300 generated events and the site's colours; its source is apps/docs/components/ScheduleDemo.tsx.

For the same frame drawn to a canvas instead, see Examples.