A high-performance, infinitely-scrolling Gantt-style timeline component built with React and Jotai.
- Infinite horizontal scrolling with seamless recentering
- Smooth zoom with anchor point preservation (Ctrl/Cmd + scroll wheel)
- Drag & drop cards with lane reordering and auto-scroll near edges
- Resize handles with cross-resize support (drag past opposite edge)
- Virtualized rendering - only visible cards are rendered
- Compound component API for full customization
- Auto-fit on mount - automatically zooms to show all cards
The timeline uses a compound component pattern where all subcomponents are accessed through the Timeline namespace:
<Timeline.Root cards={cards} onCardsChange={setCards}>
<Timeline.Scroller>
<Timeline.Header>{(day) => <DayHeader day={day} />}</Timeline.Header>
<Timeline.Lanes>{(card) => <CardContent card={card} />}</Timeline.Lanes>
<Timeline.DragOverlay>{(card) => <CardContent card={card} isDragging />}</Timeline.DragOverlay>
</Timeline.Scroller>
</Timeline.Root>Timeline.Root
├── Timeline.ZoomControls.Root (optional, positioned absolutely)
│ ├── Timeline.ZoomControls.ZoomIn
│ ├── Timeline.ZoomControls.Reset
│ │ └── Timeline.ZoomControls.Value
│ └── Timeline.ZoomControls.ZoomOut
├── Timeline.SidePanel (optional, fixed left column)
├── Timeline.Cursor (optional, follows pointer)
└── Timeline.Scroller (scrollable container)
├── Timeline.Header (sticky top, day columns)
├── Timeline.Lanes (card container with lane backgrounds)
│ └── CardWrapper (internal, positions cards)
│ └── User's card render function
│ └── Timeline.ResizeHandle (start/end)
└── Timeline.DragOverlay (floating dragged card)
State is managed with Jotai atoms organized into domains:
| File | Atoms | Purpose |
|---|---|---|
cards.ts |
cardsAtom, displayCardsAtom, numLanesAtom, applyCardUpdatesAtom |
Card data and lane reordering logic |
config.ts |
baseConfigAtom, configAtom, zoomConfigAtom |
Configuration with zoom-adjusted dayWidth |
scroll.ts |
dayOffsetAtom, visibleRangeAtom, scrollContainerRefAtom, timelineReadyAtom |
Scroll position and virtualization range |
zoom.ts |
zoomAtom, zoomAnchorAtom, setZoomAtom, zoomInAtom, zoomOutAtom |
Zoom level and anchor point management |
drag.ts |
dragAtom, pointerAtom, startDragAtom, updateDeltaAtom, endDragAtom |
Active drag state |
TimelineProvider
├── createStore() - Isolated Jotai store per timeline instance
├── HydrateTimelineAtoms - Initial hydration + prop sync
└── AutoFitOnMount - Calculates initial zoom/scroll to fit cards
The timeline achieves infinite scrolling through a scroll buffer + day offset pattern:
┌─────────────────────────────────────────────────────────────┐
│ Scroll Buffer (1000 days) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Viewport │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↑
scrollLeft
Key atoms:
dayOffset: The real-world day number at the start of the scroll buffervisibleRange:{ start, end }- which days are currently visible (for virtualization)
Recentering Logic (in Scroller.tsx):
// When scroll position is within 10% of edges:
if (scrollRatio < 0.1 || scrollRatio > 0.9) {
// 1. Calculate how far from center
const dayDelta = Math.round(scrollDelta / config.dayWidth);
// 2. Adjust dayOffset to compensate
setDayOffset(dayOffset - dayDelta);
// 3. Jump scroll to center
container.scrollLeft = centerScrollLeft;
}This creates the illusion of infinite scroll - users never hit the edges.
Zooming scales dayWidth while keeping a specific day anchored at the same screen position:
Before zoom (1x): [Day 5][Day 6][Day 7][Day 8]
↑ anchor at 100px
After zoom (2x): [ Day 5 ][ Day 6 ][ Day 7 ]
↑ anchor still at 100px
Zoom flow:
setZoomAtomcaptures anchor day + screen position BEFORE zoom- Updates
zoomAtomvalue useLayoutEffectin Scroller recalculatesscrollLeftto preserve anchor
Anchor calculation:
// Capture anchor before zoom
const anchorPixel = container.scrollLeft + anchorX;
const bufferDayPosition = anchorPixel / currentDayWidth;
const anchorDay = bufferDayPosition + dayOffset;
// After zoom - position so anchor day stays at anchorX
const newBufferPosition = anchorDay - dayOffset;
const newAnchorPixel = newBufferPosition * newDayWidth;
const newScrollLeft = newAnchorPixel - anchorX;Three drag modes are supported:
| Mode | Behavior |
|---|---|
move |
Card follows pointer, can change lanes |
resize-start |
Left edge follows pointer, right edge anchored |
resize-end |
Right edge follows pointer, left edge anchored |
Drag State:
interface DragState {
cardId: string;
lane: number;
title: string;
startDay: number;
endDay: number;
mode: DragMode;
startX: number; // Initial pointer position
startY: number;
dx: number; // Current delta from start
dy: number;
rect: DOMRect; // Card rect at drag start
}Visual Feedback Architecture:
┌─────────────────────────────────────────────┐
│ Timeline.Lanes │
│ ├── CardWrapper (dragged card: opacity: 0) │
│ └── CardWrapper (other cards: normal) │
├─────────────────────────────────────────────┤
│ Timeline.DragOverlay (position: absolute) │
│ └── Floating card at pointer position │
└─────────────────────────────────────────────┘
The original card becomes invisible (opacity: 0) while DragOverlay renders a floating copy.
When dragging a card between lanes, other cards shift to make room:
// displayCardsAtom computes visual positions during drag
const targetY = drag.rect.top + drag.rect.height / 2 + drag.dy;
const targetLane = findLaneAtY(targetY); // Uses [data-lane] attributes
if (targetLane !== sourceLane) {
// Shift cards between source and target lanes
if (movingDown) {
// Cards in between shift UP
for (lane = sourceLane + 1; lane <= targetLane; lane++) {
card.lane = lane - 1;
}
} else {
// Cards in between shift DOWN
for (lane = targetLane; lane < sourceLane; lane++) {
card.lane = lane + 1;
}
}
}When dragging near edges, the container auto-scrolls:
// auto-scroll.ts
const SCROLL_EDGE_PX = 60;
const MAX_SCROLL_SPEED = 20;
function tick() {
if (pointerX < containerRect.left + SCROLL_EDGE_PX) {
// Scroll left, speed proportional to edge proximity
deltaX = -(d / SCROLL_EDGE_PX) * MAX_SCROLL_SPEED;
}
// Similar for right, top, bottom edges
container.scrollLeft += deltaX;
container.scrollTop += deltaY;
}Users can drag a resize handle past the opposite edge:
Before: [████START████████████████████END█]
← drag start handle past end →
After: [████END██████████████████████START█]
DragOverlay detects this crossing and swaps the visual handles:
if (mode === "resize-start" && snappedStartDay >= endDay) {
// Dragged past: show swapped state
absoluteLeft = (endDay - dayOffset) * dayWidth;
width = (snappedStartDay + 1 - endDay) * dayWidth;
isCrossed = true;
effectivePosition = "end"; // Handle visually on opposite side
}interface TimelineConfig {
dayWidth: number; // Base width per day (default: 80px)
laneHeight: number; // Height per lane (default: 60px)
scrollBufferDays: number; // Total days in scroll buffer (default: 100)
recenterThreshold: number;// Edge % to trigger recenter (default: 0.1)
resizeHandleWidth: number;// Resize handle hit area (default: 12px)
}interface ZoomConfig {
minZoom: number; // Minimum zoom level (default: 0.25)
maxZoom: number; // Maximum zoom level (default: 3)
zoomStep: number; // Increment per zoom action (default: 0.05)
defaultZoom: number;// Initial zoom (default: 1)
}interface BaseCard {
id: string; // Unique identifier
lane: number; // 0-indexed lane position
startDay: number; // Start day (0 = today, negative = past)
endDay: number; // End day (exclusive)
}Extend BaseCard for custom properties:
interface TaskCard extends BaseCard {
title: string;
color: string;
assignee: string;
}- Virtualization: Only cards in
visibleRangeare rendered - Scroll buffer: Large buffer (1000 days) minimizes recenter frequency
- Zoom throttling: RAF-based throttling limits to one zoom per frame
- Pointer capture: Cards capture pointer to prevent event loss during fast drags
- Isolated stores: Each timeline instance has its own Jotai store
import { Timeline } from "@/components/timeline";
function App() {
const [cards, setCards] = useState<TaskCard[]>([
{ id: "1", title: "Task A", lane: 0, startDay: 0, endDay: 5 },
{ id: "2", title: "Task B", lane: 1, startDay: 3, endDay: 10 },
]);
return (
<Timeline.Root
cards={cards}
onCardsChange={setCards}
config={{ scrollBufferDays: 1000 }}
>
<Timeline.Scroller>
<Timeline.Header dayClassName={(day) => day.isWeekend && "bg-gray-100"}>
{(day) => <span>{day.date.toLocaleDateString()}</span>}
</Timeline.Header>
<Timeline.Lanes
cardClassName={(card, isDragging) => isDragging && "opacity-0"}
renderLaneBackground={({ laneIndex, top, height, width }) => (
<div data-lane={laneIndex} style={{ top, height, width }} />
)}
>
{(card) => (
<div className="flex h-full items-center bg-blue-500 px-2">
<Timeline.ResizeHandle position="start" className="cursor-ew-resize">
<div className="h-4 w-1 bg-white/50" />
</Timeline.ResizeHandle>
{card.title}
<Timeline.ResizeHandle position="end" className="cursor-ew-resize">
<div className="h-4 w-1 bg-white/50" />
</Timeline.ResizeHandle>
</div>
)}
</Timeline.Lanes>
<Timeline.DragOverlay>
{(card) => (
<div className="h-full bg-blue-500 opacity-90 shadow-lg">
{card.title}
</div>
)}
</Timeline.DragOverlay>
</Timeline.Scroller>
</Timeline.Root>
);
}components/timeline/
├── atoms/
│ ├── cards.ts # Card state, lane logic, displayCards
│ ├── config.ts # Config with zoom-adjusted dayWidth
│ ├── drag.ts # Drag state machine
│ ├── scroll.ts # dayOffset, visibleRange, refs
│ ├── zoom.ts # Zoom with anchor preservation
│ └── index.ts # Re-exports
├── auto-scroll.ts # Edge-based auto-scroll during drag
├── cursor.tsx # Pointer follower with date display
├── drag-overlay.tsx # Floating card during drag
├── header.tsx # Day column headers
├── index.ts # Public API exports
├── lanes.tsx # Card positioning + CardContext
├── provider.tsx # Jotai store + atom hydration
├── resize-handle.tsx # Drag handles for card edges
├── root.tsx # Entry point + AutoFitOnMount
├── scroller.tsx # Scroll container + zoom handling
├── side-panel.tsx # Fixed left column for labels
├── types.ts # Type definitions + defaults
├── use-card-drag.ts # Drag hook for cards
└── zoom-controls.tsx # Zoom UI compound components
- React 18+ - Concurrent features, useLayoutEffect
- Jotai - Atomic state management
- jotai/utils -
useHydrateAtoms,useAtomCallback - Tailwind CSS - Styling (via
cnutility)