Skip to content

utopyin/linear-timeline

Repository files navigation

Linear Timeline

A high-performance, infinitely-scrolling Gantt-style timeline component built with React and Jotai.

Features

  • 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

Architecture Overview

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>

Component Hierarchy

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 Management

State is managed with Jotai atoms organized into domains:

Atoms Structure (/atoms/)

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

Provider Architecture

TimelineProvider
├── createStore() - Isolated Jotai store per timeline instance
├── HydrateTimelineAtoms - Initial hydration + prop sync
└── AutoFitOnMount - Calculates initial zoom/scroll to fit cards

Core Concepts

1. Infinite Scroll with Day Offset

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 buffer
  • visibleRange: { 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.

2. Zoom System with Anchor Preservation

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:

  1. setZoomAtom captures anchor day + screen position BEFORE zoom
  2. Updates zoomAtom value
  3. useLayoutEffect in Scroller recalculates scrollLeft to 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;

3. Drag & Drop System

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.

4. Lane Reordering During Drag

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;
    }
  }
}

5. Auto-Scroll During Drag

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;
}

6. Cross-Resize Support

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
}

Configuration

TimelineConfig

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)
}

ZoomConfig

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)
}

Card Data Model

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;
}

Performance Optimizations

  1. Virtualization: Only cards in visibleRange are rendered
  2. Scroll buffer: Large buffer (1000 days) minimizes recenter frequency
  3. Zoom throttling: RAF-based throttling limits to one zoom per frame
  4. Pointer capture: Cards capture pointer to prevent event loss during fast drags
  5. Isolated stores: Each timeline instance has its own Jotai store

Usage Example

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>
  );
}

File Structure

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

Dependencies

  • React 18+ - Concurrent features, useLayoutEffect
  • Jotai - Atomic state management
  • jotai/utils - useHydrateAtoms, useAtomCallback
  • Tailwind CSS - Styling (via cn utility)

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published