Getting started

Install schedgrid and render a first grid, with or without React.

Install

pnpm add schedgrid

The 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]
    },
  ],
});