Getting started
Install schedgrid and render a first grid, with or without React.
Install
pnpm add schedgridThe package is ESM only and ships four sub-path exports. React is an
optional peer dependency: install it only if you use schedgrid/react.
Without a framework
The engine needs two things from the outside world: the size of the viewport and the current scroll offset. Everything else is derived.
import { createGrid } from 'schedgrid/core';
import { bindScrollElement } from 'schedgrid/dom';
const grid = createGrid({
rows: { count: 200, size: 40, pinned: 1 },
columns: { count: 97, size: 24, pinned: 1 },
items: [
{ id: 'a', rows: [1, 2], columns: [30, 38] },
{ id: 'b', rows: [1, 2], columns: [34, 41] }, // overlaps `a`: gets a lane
],
});
const scroller = document.querySelector<HTMLElement>('#scroller')!;
const unbind = bindScrollElement(grid, scroller);
grid.subscribe(() => {
const frame = grid.getFrame();
// frame.rows, frame.columns and frame.items are plain numbers in
// content space; paint them however you like.
});bindScrollElement reads the element's size up front, moves it to the grid's
scroll offset, and keeps the two in sync from then on in both directions. Call
unbind() when the element goes away.
With React
The hooks wrap the same engine. useGrid creates it once per component,
useFrame subscribes to it, and useScrollContainer returns a callback ref
for the scrollable element.
import { useFrame, useGrid, useScrollContainer } from 'schedgrid/react';
import type { Item } from 'schedgrid/core';
const items: Item[] = [
{ id: 'a', rows: [1, 2], columns: [30, 38] },
{ id: 'b', rows: [1, 2], columns: [34, 41] }, // overlaps `a`: gets a lane
];
export function Schedule() {
const grid = useGrid({
rows: { count: 200, size: 40, pinned: 1 },
columns: { count: 97, size: 24, pinned: 1 },
items,
});
const frame = useFrame(grid);
const ref = useScrollContainer(grid);
return (
<div ref={ref} style={{ height: 400, overflow: 'auto' }}>
<div
style={{
position: 'relative',
width: frame.total.width,
height: frame.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.id}
</div>
))}
</div>
</div>
);
}The frame object is memoised by the engine, so React bails out of re-renders whenever nothing relevant changed. See the React page for a complete renderer with pinned rows and columns.
Mapping time onto columns
Items live in index space. schedgrid/time turns a time range into an axis
and turns moments into fractional indices:
import { createGrid } from 'schedgrid/core';
import { timeAxis } from 'schedgrid/time';
const day = timeAxis({
start: new Date(2026, 8, 7),
end: new Date(2026, 8, 8),
slot: 15 * 60_000, // one column per 15 minutes
size: 24,
});
const grid = createGrid({
rows: { count: 50, size: 40 },
columns: day.axis,
items: [
{
id: 'standup',
rows: [0, 1],
columns: day.toRange(
new Date(2026, 8, 7, 9, 37),
new Date(2026, 8, 7, 10, 30),
), // [38.47, 42]
},
],
});