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.
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.