@itauros/react-timeline
v1.0.1
Published
Headless, composable React timeline/gantt engine. Owns the data model, layout math, interaction state, and virtualization — you own every pixel.
Maintainers
Readme
@itauros/react-timeline
Headless, composable React timeline / gantt engine. Inspired by TanStack Table — the library owns the data model, layout math, interaction state, and virtualization; you own every pixel.
- 🎯 Headless — zero styled components. It computes; you render.
- ⚡ Two-axis virtualization — rows and time columns. Built for 10k+ items.
- 🧮 Native-
Datetime engine — no dayjs/Luxon dependency; DST- and calendar-correct. - 🧩 Plugin architecture — drag, resize, create, dependencies, critical path, baselines, history, keyboard, export, collaboration, a11y. All opt-in and tree-shakeable.
- 🧬 Fully generic — your item/group payloads flow through with no
ascasts. - 🪶 Tiny — ESM + CJS,
sideEffects: false, one peer dep (React).
Table of contents
- Install
- Quick start
- Design & philosophy
- Core concepts
- DOM / layout contract
- API reference
- Plugins
- Utilities (
/utils) - Accessibility (
/a11y) - TypeScript
- Performance
- Recipes
- Development
- Contact
- License
Install
pnpm add @itauros/react-timeline
# peer deps
pnpm add react react-domEntry points:
| Import | Contents |
| --- | --- |
| @itauros/react-timeline | useTimeline, render primitives, context, types |
| @itauros/react-timeline/plugins | all feature plugins (tree-shakeable) |
| @itauros/react-timeline/utils | time-scale + native-Date + layout helpers |
| @itauros/react-timeline/a11y | accessibility plugin |
Quick start
import {
useTimeline, TimelineRoot, TimelineSidebar, TimelineGroupRow,
TimelineCanvas, TimelineHeader, TimelineGrid, TimelineGridColumn,
TimelineItems, TimelineItem, TimelineNowMarker,
} from '@itauros/react-timeline';
import { dragPlugin, resizePlugin } from '@itauros/react-timeline/plugins';
import { createTimeScale } from '@itauros/react-timeline/utils';
type Person = { name: string };
type Assignment = { project: string; pct: number; color: string };
function Schedule({ people, assignments, onMove }: Props) {
const tl = useTimeline<Assignment, Person>({
items: assignments, // TimelineItem<Assignment>[]
groups: people, // TimelineGroup<Person>[]
timeScale: createTimeScale({ unit: 'day', cellWidth: 44 }),
rowHeight: 56,
onItemMove: (item, start, end, groupId) => onMove(item.id, start, end, groupId),
plugins: [resizePlugin({ snapTo: 'day' }), dragPlugin({ snapTo: 'day' })],
});
return (
<TimelineRoot timeline={tl} style={{ height: 480 }}>
<TimelineSidebar style={{ width: 200 }}>
{tl.virtualRows.map((row) => (
<TimelineGroupRow key={row.group.id} group={row.group}>
{row.group.data.name} {/* ✓ typed as Person */}
</TimelineGroupRow>
))}
</TimelineSidebar>
<TimelineCanvas>
<TimelineHeader /> {/* auto-renders all header levels */}
<TimelineGrid>
{tl.virtualCols.map((c) => (
<TimelineGridColumn key={c.id} col={c}
style={{ background: c.isNonWorking ? '#f4f4f5' : undefined }} />
))}
</TimelineGrid>
<TimelineItems>
{tl.virtualRows.flatMap((row) =>
row.items.map((pi) => (
<TimelineItem key={pi.item.id} item={pi}
style={{ background: pi.item.data.color }}>
{pi.item.data.project} {/* ✓ typed as Assignment */}
</TimelineItem>
)),
)}
</TimelineItems>
<TimelineNowMarker />
</TimelineCanvas>
</TimelineRoot>
);
}Run the interactive demo from the repo: pnpm demo.
Design
The library has one opinion: what to compute. It never decides what a timeline looks like.
- It owns the data model (items, groups, dependencies), the layout math (date↔pixel mapping, nested rows, collision lanes), the interaction state (a single state machine so drag/resize/select never conflict), and virtualization (only what's near the viewport is rendered).
- You own rendering — every element is an unstyled slot you fill with your own markup and styles (Tailwind, CSS modules, inline, anything).
- Features beyond layout are plugins. Import only what you use.
- Zero runtime dependencies except React. The time engine is built on the
native
Datewith calendar-correct, DST-tolerant arithmetic.
Core concepts
Data model
Everything is generic over your own payload shapes T (item) and G (group):
type TimelineItem<T = unknown> = {
id: string;
groupId: string; // which group/row it belongs to
start: Date;
end: Date;
data: T; // arbitrary, fully-typed payload
};
type TimelineGroup<G = unknown> = {
id: string;
parentId?: string; // enables arbitrary nesting depth
data: G;
};
type Dependency = {
id: string;
fromItemId: string;
toItemId: string;
type: 'finish-to-start' | 'start-to-start' | 'finish-to-finish' | 'start-to-finish';
lag?: number; // ms offset
data?: unknown;
};Groups form a tree via parentId; the row model is a depth-first flatten that
skips collapsed subtrees. Items that overlap in time within the same group are
automatically stacked into lanes so they never visually collide.
Time scale
The scale is a first-class object, not a hardcoded zoom level. Zoom is just a
different cellWidth.
type TimeScale = {
unit: 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year';
cellWidth: number; // px per unit
format?: (date: Date) => string;
headerLevels?: { unit: TimeUnit; format?: (d: Date) => string }[];
nonWorkingPredicate?: (date: Date) => boolean; // grey out weekends/holidays
snapPredicate?: (date: Date) => Date; // snap-to-grid
};Build and transform scales with the helpers in /utils:
import { createTimeScale, zoomIn, zoomOut, fitToItems, fitToRange }
from '@itauros/react-timeline/utils';
const day = createTimeScale({
unit: 'day', cellWidth: 44,
headerLevels: [{ unit: 'month' }, { unit: 'week' }, { unit: 'day' }],
});
const zoomed = zoomIn(day, { factor: 1.25 }); // just a bigger cellWidth
const fitted = fitToItems(items, containerWidth); // auto-pick unit + cellWidthVirtualization
Both axes are virtualized independently. Only rows within the vertical viewport
(± overscan) and items/columns within the horizontal viewport (± overscan) are
materialized. tl.virtualRows, tl.virtualCols, and row.items already contain
exactly what you should render this frame.
Interaction state machine
All pointer interactions are modelled as one discriminated union, exposed as
tl.interactionState, so plugins coordinate instead of fighting:
type InteractionState =
| { type: 'idle' }
| { type: 'hovering'; itemId: string }
| { type: 'dragging'; itemId: string; itemIds: string[]; originalRect; currentRect; delta; targetGroupId }
| { type: 'resizing'; itemId: string; edge: 'left' | 'right'; originalRect; currentRect }
| { type: 'creating'; groupId: string; start: Date; end: Date }
| { type: 'connecting'; fromItemId: string; toPosition: Point }
| { type: 'rubberBand'; start: Point; end: Point; selectedIds: string[] };Read it in your render to draw live previews (e.g. a "ghost" while creating).
DOM / layout contract
TimelineRoot is a flexbox row: TimelineSidebar (left) and
TimelineCanvas (right). The canvas contains the single real scroll container;
the header strip and the sidebar are scroll-synced via CSS transforms.
This keeps one clean coordinate space — the canvas content origin is (0, 0) —
for all layout math and virtualization.
TimelineRoot (display:flex)
├─ TimelineSidebar row headers, vertically synced (translateY)
└─ TimelineCanvas
├─ TimelineHeader time axis, horizontally synced (translateX) ← routed here
└─ scroll container the only real scroller
└─ content (totalWidth × totalHeight)
├─ TimelineGrid / TimelineGridColumn
├─ TimelineRegions / TimelineRegion
├─ TimelineItems / TimelineItem
├─ TimelineDependencies / TimelineDependencyArrow
└─ TimelineNowMarkerTimelineCanvas inspects its children and routes the <TimelineHeader> into the
synced header strip (matched by a __tlSlot marker); everything else goes into
the scrollable content. All rects from getItemRect, getDateX, etc. are in
that content space.
API reference
useTimeline(options)
const tl = useTimeline<TItem, TGroup>(options);Key options (see full TSDoc in your editor for every field):
| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| items | TimelineItem<T>[] | — | required |
| groups | TimelineGroup<G>[] | — | required |
| timeScale | TimeScale | — | required |
| dependencies | Dependency[] | [] | |
| visibleStart / visibleEnd | Date | derived from items | the modelled canvas range |
| onVisibleRangeChange | (start, end) => void | — | fires on scroll/zoom; use for lazy data fetching |
| rowHeight | number \| (group) => number | 44 | |
| rowPadding | number | 8 | |
| groupOrder | (a, b) => number | — | sort sibling groups |
| weekStartsOn | 0 \| 1 | 1 (Mon) | week alignment |
| headerLevelHeight | number | 28 | px per header row |
| selectionMode | 'single' \| 'multi' \| 'range' | 'single' | |
| selectedIds / onSelectionChange | controlled selection | uncontrolled | |
| collapsedIds / onCollapsedChange | controlled collapse | uncontrolled | |
| overscan | number | 4 | extra rows/cols rendered off-screen |
| onItemMove / onItemResize / onItemCreate / onDependencyCreate | callbacks fired by plugins | — | apply optimistic updates here |
| plugins | TimelinePlugin[] | [] | |
Selection and collapse are controlled if you pass the prop, uncontrolled otherwise (the standard React pattern).
The TimelineInstance
Returned by useTimeline; passed to <TimelineRoot timeline={tl}>.
// layout
getItemRect(id): Rect | null
getGroupRowRect(id): Rect | null
getDateX(date): number
getXDate(x): Date
// virtualized slices to render
virtualRows: VirtualRow[] // visible rows, each with its visible items
virtualCols: VirtualCol[] // visible base-unit columns
headerCols: HeaderCol[][] // one array per header level
totalWidth, totalHeight, headerHeight, viewport
// state
getItemState(id): { rect, lane, isSelected, isDragging, isResizing, isColliding, ...plugin flags }
getGroupState(id): { depth, isCollapsed, childIds, itemIds, utilizationRatio }
interactionState: InteractionState
// prop getters (wire to DOM — the primitives do this for you)
getItemProps(id), getGroupProps(id), getCanvasProps(), getScrollContainerProps()
// actions
scrollToDate(date, align?, behavior?) scrollToItem(id) scrollToGroup(id)
setTimeScale(scale)
collapseGroup(id, recursive?) expandGroup(id, recursive?) isCollapsed(id)
setSelection(ids) toggleSelection(id, additive?)
undo() redo() canUndo() canRedo() // no-ops unless historyPlugin is installed
// + any methods contributed by plugins (exportAs, getCriticalPath, getCollaborators, …)Render primitives
All are unstyled and accept className / style / standard HTML props.
| Primitive | Renders in | Purpose |
| --- | --- | --- |
| TimelineRoot | — | provides context + flex shell |
| TimelineSidebar | Root | left pane (synced vertically) |
| TimelineGroupRow | Sidebar | one row header, positioned at its Y |
| TimelineCanvas | Root | main pane; routes the header child |
| TimelineHeader | Canvas | auto-renders all header levels (or pass renderCell, or full-control children) |
| TimelineHeaderCell | custom header | a single positioned header cell |
| TimelineGrid / TimelineGridColumn | Canvas | background grid columns |
| TimelineItems / TimelineItem | Canvas | the item bars (also exported as TimelineBar) |
| TimelineDependencies / TimelineDependencyArrow | Canvas | SVG dependency arrows (dependencyPath helper for custom paths) |
| TimelineNowMarker | Canvas | vertical "now" line |
| TimelineRegions / TimelineRegion | Canvas | highlight bands (sprints, phases, frozen periods) |
TimelineItem is both the component and the data-model type (merged), so
<TimelineItem item={…}> and TimelineItem<Assignment> both work.
Plugins
import {
dragPlugin, resizePlugin, createPlugin, selectionPlugin,
dependencyPlugin, criticalPathPlugin, baselinePlugin,
historyPlugin, keyboardPlugin, exportPlugin, collaborationPlugin,
} from '@itauros/react-timeline/plugins';
import { a11yPlugin } from '@itauros/react-timeline/a11y';Pass them to useTimeline({ plugins: [...] }). Order matters — the
recommended order is selection → resize → drag → create (resize claims edge
grabs, drag the interior, selection runs first so a drag starts from a selection).
| Plugin | What it adds |
| --- | --- |
| dragPlugin | Move items in time and across groups. Options: snapTo, axis ('x' \| 'y' \| 'both'), constrainToGroup, onDragStart/End. Fires onItemMove. |
| resizePlugin | Resize an item by grabbing an edge. Options: snapTo, minDuration, handles, edgeThreshold. Snaps live during the preview. Fires onItemResize. |
| createPlugin | Drag on empty canvas to sketch a new item. Options: snapTo, minDuration, onCreate. Requires an actual drag (a click won't create). |
| selectionPlugin | Click / shift-click / ctrl-click and optional rubber-band selection. Options: mode, rubberBand, keyboard. |
| dependencyPlugin | Alt-drag from one item to another to connect, with cycle prevention. Options: editable, cyclePrevention, onCreateDependency/onDeleteDependency. |
| criticalPathPlugin | Computes the CPM longest path; adds isCritical to item state and tl.getCriticalPath(). |
| baselinePlugin | Renders a planned snapshot behind live bars; adds baselineRect/baselineDriftPx and tl.getBaselineRect(id). |
| historyPlugin | Command-based undo/redo. Drag/resize push inverse commands; enables tl.undo/redo/canUndo/canRedo. |
| keyboardPlugin | Arrow move/resize, Delete, Escape, mod+z/y, select-all. Configurable bindings, moveStep, resizeStep. |
| exportPlugin | tl.exportAs('json' \| 'csv' \| 'svg' \| 'png' \| 'pdf'). Renders a vector snapshot from the layout. |
| collaborationPlugin | Broadcast cursors/selection/moves over any channel (BroadcastChannel, WebSocket…). Adds tl.getCollaborators(). |
| a11yPlugin | Per-item aria-labels, a polite live region for drag/resize announcements, reduced-motion signalling. |
Writing a plugin
A plugin is a plain object. Hooks: setup, onCanvasPointerDown /
onItemPointerDown / onPointerMove / onPointerUp (return true to consume),
onKeyDown, extendItemState (add flags/override the rect), itemProps /
canvasProps (merge HTML props), and methods (merged onto the instance).
const hoverPlugin: TimelinePlugin = {
name: 'hover',
extendItemState: (id, state, ctx) =>
ctx.getInteraction().type === 'hovering' ? { isHot: true } : undefined,
};Utilities (/utils)
Pure functions — safe to import standalone, no React required.
- Scale:
createTimeScale,zoomIn,zoomOut,fitToItems,fitToRange,dateToX,xToDate,resolveScale,totalWidthFor,generateCols,generateHeaderCols. - Native-
Dateengine:startOfUnit,endOfUnit,addUnits,floorUnits,unitsBetween,countUnits,daysBetween,quarterOf,isWeekend,isoWeek,defaultFormat,clone,msPerUnitNominal. - Layout:
buildGroupMaps,buildRowModel,layoutRowItems,visibleRowRange.
Accessibility (/a11y)
a11yPlugin adds per-item aria-labels, a visually-hidden polite live region
that announces drag/resize/create outcomes, and a data-reduced-motion
attribute (honoring prefers-reduced-motion). The core already applies
role="grid" | "row" | "gridcell" via the prop getters and supports keyboard
focus on the canvas.
TypeScript
Types flow through end to end — no casting:
const tl = useTimeline<Assignment, Person>({ items, groups, /* … */ });
tl.virtualRows[0].group.data.firstName; // ✓ Person
tl.virtualRows[0].items[0].item.data.color; // ✓ AssignmentGenerics: T is your item payload, G your group payload. Plugins are generic
too (dragPlugin<Assignment, Person>()), though inference usually suffices.
Performance
Targets: 10,000 items / 500 rows at 60 fps pan & zoom; initial render < 100 ms; optimistic reposition < 16 ms.
- Both axes virtualized; only near-viewport rows/cols/items render.
- The full item layout (rect + lane + collision) is memoized and recomputed only
when data or scale changes —
O(n log n), not per scroll frame. - Item rects are positioned absolutely so reflows are cheap; drag/resize update
only the active item's rect via
extendItemState.
Recipes
Live "ghost" preview while creating — read the interaction state:
const st = tl.interactionState;
{st.type === 'creating' && (() => {
const row = tl.getGroupRowRect(st.groupId)!;
const x = tl.getDateX(st.start < st.end ? st.start : st.end);
const w = Math.abs(tl.getDateX(st.end) - tl.getDateX(st.start));
return <div style={{ position: 'absolute', left: x, top: row.y, width: w, height: row.height }} />;
})()}Lazy-load data for the visible window:
useTimeline({
/* … */
onVisibleRangeChange: (start, end) => fetchAssignments(start, end), // debounce this
});Optimistic move with undo (with historyPlugin):
useTimeline({
onItemMove: (item, start, end, groupId) =>
setItems(prev => prev.map(i => i.id === item.id ? { ...i, start, end, groupId } : i)),
plugins: [historyPlugin(), dragPlugin({ snapTo: 'day' })],
});Development
pnpm install
pnpm build # tsup → ESM + CJS + d.ts for all entry points
pnpm test # vitest (date / layout / CPM math)
pnpm typecheck
pnpm demo # Vite playground at localhost:5180Contact
For support, licensing, partnerships, security reports, or general questions, contact ITAUROS at [email protected].
License
MIT © ITAUROS
