Skip to content
ilinxa/pro-ui

Gantt Timeline

alphav0.7.1

Editable Gantt timeline — per-task bars, collapsible summary rows, milestone diamonds, continuous zoom from hours to quarters, and a today line.

Category: Data DisplayUpdated: 2026-08-19Created: 2026-06-20Author: ilinxa

Context

Gantt Timeline is the time-axis sibling of task-card (cards) and kanban-board (columns): it consumes the SAME canonical TaskItem type and lays it on a horizontal time axis, so a 'Timeline' tab is literally the same task data the List + Board tabs show, on a third surface. v0.2 makes it editable: opt in with `editable` and edits fire task-card-shaped events + onChange(TaskItem[]) for the controlled consumer to echo (no internal data state). The permission matrix (TaskPermissions) + per-action predicates are reused from task-card — the gantt is the third consumer after the card and task-tree. Use it for sprint/cycle planning, delivery roadmaps (collapse to epics), content embargo→sunset schedules, and agent/pipeline run windows. Compound structure: GanttTimelineRoot (headless provider) + flat parts (Toolbar/Axis/Gutter/Body) + Tier-C primitives (GanttBar/SummaryBar/MilestoneDiamond/TodayLine/GutterRow/AxisHeader/BarTooltip/GanttContextMenu) + the GanttTimeline assembly. The full-card tooltip and the edit editor lazy-load task-card, so the default read-only path keeps it out of the bundle.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/gantt-timeline

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/gantt-timeline-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Drag to pan · ⌘/ctrl-wheel or pinch to zoom · click a bar
ASQ3 product launchIn progress
LPDesign system auditDone
LPBuild component libraryIn progress
Buttons + inputsDone
Data tableIn progress
API contract sign-offTo do
MCInfrastructure hardeningIn progress
Migrate to new clusterBlocked
Load testingTo do
Decommission legacy stackTo do
SRMarketing site refreshIn progress
Public launchTo do

Demo source

demo.tsxtsx
"use client"; import { useRef, useState } from "react";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import {  GanttTimeline,  GanttTimelineAxis,  GanttTimelineBody,  GanttTimelineGutter,  GanttTimelineRoot,  GanttTimelineSkeleton,  GanttFullCardTooltip,} from "./";import type { GanttTimelineHandle, TaskItem } from "./";import {  GANTT_DUMMY,  GANTT_LABEL_OPTIONS,  GANTT_PRIORITY_OPTIONS,  GANTT_STATUS_OPTIONS,} from "./dummy-data"; export default function GanttTimelineDemo() {  const handle = useRef<GanttTimelineHandle>(null);  const [clicked, setClicked] = useState<TaskItem | null>(null);   return (    <Tabs defaultValue="timeline" className="w-full gap-4">      <SwipeTabsList>        <TabsTrigger value="timeline">Timeline</TabsTrigger>        <TabsTrigger value="editable">Editable</TabsTrigger>        <TabsTrigger value="composed">Composed (lighter)</TabsTrigger>        <TabsTrigger value="states">States</TabsTrigger>        <TabsTrigger value="full-card">Full-card tooltip</TabsTrigger>      </SwipeTabsList>       {/* 1 — full assembly + imperative handle */}      <TabsContent value="timeline" className="space-y-3">        <div className="flex flex-wrap items-center gap-2">          <Button size="sm" variant="outline" onClick={() => handle.current?.scrollToToday()}>            Scroll to today          </Button>          <Button size="sm" variant="outline" onClick={() => handle.current?.zoomToFit()}>            Fit all          </Button>          <Button size="sm" variant="outline" onClick={() => handle.current?.expandAll()}>            Expand all          </Button>          <Button size="sm" variant="outline" onClick={() => handle.current?.collapseAll()}>            Collapse all          </Button>          <Button size="sm" variant="outline" onClick={() => handle.current?.scrollToItem("m-1")}>            Jump to launch          </Button>          <span className="ml-auto text-xs text-muted-foreground">            {clicked ? `Clicked: ${clicked.name}` : "Drag to pan · ⌘/ctrl-wheel or pinch to zoom · click a bar"}          </span>        </div>        <GanttTimeline          ref={handle}          data={GANTT_DUMMY}          statusOptions={GANTT_STATUS_OPTIONS}          priorityOptions={GANTT_PRIORITY_OPTIONS}          labelOptions={GANTT_LABEL_OPTIONS}          showWeekendShading          defaultZoom="week"          now={new Date("2026-06-20T12:00:00.000Z")}          onTaskClick={setClicked}          aria-label="Q3 plan timeline"        />      </TabsContent>       {/* 1b — editable: full CRUD + drag/resize + reparent + a consumer-owned undo stack */}      <TabsContent value="editable">        <EditableDemo />      </TabsContent>       {/* 2 — hand-assembled subset proving the compound (Root + parts, no toolbar) */}      <TabsContent value="composed">        <GanttTimelineRoot          data={GANTT_DUMMY}          statusOptions={GANTT_STATUS_OPTIONS}          labelOptions={GANTT_LABEL_OPTIONS}          defaultZoom="day"          now={new Date("2026-06-20T12:00:00.000Z")}          className="overflow-hidden rounded-lg border border-border bg-card"        >          <div className="flex items-center justify-between border-b border-border px-3 py-2">            <span className="text-sm font-medium text-foreground">Sprint board</span>            <span className="text-xs text-muted-foreground">              Root + Gutter + Body — no toolbar, custom header            </span>          </div>          <GanttTimelineAxis />          <div className="flex h-105">            <GanttTimelineGutter />            <GanttTimelineBody />          </div>        </GanttTimelineRoot>      </TabsContent>       {/* 3 — empty / loading / gestures-off */}      <TabsContent value="states" className="space-y-6">        <div className="space-y-2">          <p className="text-sm font-medium text-foreground">Loading skeleton</p>          <GanttTimelineSkeleton rows={6} />        </div>        <div className="space-y-2">          <p className="text-sm font-medium text-foreground">Empty</p>          <GanttTimeline data={[]} />        </div>        <div className="space-y-2">          <p className="text-sm font-medium text-foreground">            Gestures disabled (toolbar + keyboard still work)          </p>          <GanttTimeline            data={GANTT_DUMMY}            statusOptions={GANTT_STATUS_OPTIONS}            disableGestures            now={new Date("2026-06-20T12:00:00.000Z")}          />        </div>      </TabsContent>       {/* 4 — full-card tooltip (lazy-loads task-card) */}      <TabsContent value="full-card" className="space-y-2">        <p className="text-sm text-muted-foreground">          Hover a bar — <code className="font-mono">renderTooltip</code> embeds the full          <code className="font-mono"> task-card</code> (lazy-loaded only here).        </p>        <GanttTimeline          data={GANTT_DUMMY}          statusOptions={GANTT_STATUS_OPTIONS}          labelOptions={GANTT_LABEL_OPTIONS}          defaultZoom="week"          now={new Date("2026-06-20T12:00:00.000Z")}          renderTooltip={(item) => <GanttFullCardTooltip item={item} />}        />      </TabsContent>    </Tabs>  );} /** * Editable example. Data is controlled — the host owns it and echoes `onChange`. * Undo/redo is therefore free: a plain history stack of the forests `onChange` * hands over (the v0.2 D19 recipe). */function EditableDemo() {  const [hist, setHist] = useState<{ stack: TaskItem[][]; cursor: number }>({    stack: [GANTT_DUMMY],    cursor: 0,  });  const data = hist.stack[hist.cursor];  const push = (next: TaskItem[]) =>    setHist((h) => ({      stack: [...h.stack.slice(0, h.cursor + 1), next],      cursor: h.cursor + 1,    }));  const undo = () =>    setHist((h) => ({ ...h, cursor: Math.max(0, h.cursor - 1) }));  const redo = () =>    setHist((h) => ({      ...h,      cursor: Math.min(h.stack.length - 1, h.cursor + 1),    }));   return (    <div className="space-y-3">      <div className="flex flex-wrap items-center gap-2">        <Button          size="sm"          variant="outline"          disabled={hist.cursor === 0}          onClick={undo}        >          Undo        </Button>        <Button          size="sm"          variant="outline"          disabled={hist.cursor === hist.stack.length - 1}          onClick={redo}        >          Redo        </Button>        <span className="ml-auto text-xs text-muted-foreground">          Drag a bar to move · drag edges to resize · drag a summary bracket to          move the whole group · toggle <strong>Draw</strong>, then drag an empty          row to create (off = pan) · double-click or right-click a bar to edit ·          drag the gutter grip to reparent · + / trash on row hover · Delete key        </span>      </div>      <GanttTimeline        data={data}        editable        statusOptions={GANTT_STATUS_OPTIONS}        priorityOptions={GANTT_PRIORITY_OPTIONS}        labelOptions={GANTT_LABEL_OPTIONS}        defaultZoom="week"        now={new Date("2026-06-20T12:00:00.000Z")}        onChange={push}        aria-label="Editable Q3 plan timeline"      />    </div>  );} 

Usage

When to use

Reach for GanttTimeline when you already render the canonical TaskItem[] as cards (task-card) or columns (kanban-board) and want a third surface that answers “what runs when, and what overlaps?”— a time axis the other surfaces can't provide. Editing (drag-to-reschedule, resize, create, delete, reparent, detail-edit) is opt-in via editable — off by default, so it stays a clean read-only timeline until you want more.

Basic example

import { GanttTimeline } from "@/components/gantt-timeline"

export function TasksTimeline({ tasks }) {
  return (
    <GanttTimeline
      data={tasks}                  // the same TaskItem[] as List + Board
      statusOptions={STATUS_OPTIONS}
      defaultZoom="week"
      onTaskClick={(t) => openTaskDetail(t.id)}
    />
  )
}

Composed (lighter)

Drop the assembly and hand-place the parts. GanttTimelineRoot holds all state + gestures; any subset of GanttTimelineGutter / GanttTimelineAxis / GanttTimelineBody wires itself through context — no prop-drilling.

<GanttTimelineRoot data={tasks} statusOptions={STATUS_OPTIONS} defaultZoom="day">
  <GanttTimelineAxis />
  <div className="flex h-105">
    <GanttTimelineGutter />
    <GanttTimelineBody />
  </div>
</GanttTimelineRoot>

Editing (v0.2–v0.4, opt-in)

Set editable and wire onChange — data is controlled, so you echo the mutated TaskItem[] back (and get undo/redo for free from a history stack). Drag bars to move, drag edges to resize, double-click or right-click to edit, drag the gutter grip to reparent, and drag a summary bracket to group-move the whole subtree (v0.3). To draw a new task, flip the toolbar Draw toggle (v0.4) and drag on an empty row — with Draw off, an empty-row drag pans instead. Gated by the same permissions matrix as task-card / task-tree.

<GanttTimeline
  data={tasks}
  editable
  onChange={setTasks}                 // controlled echo (source of truth)
  permissions={{ byItem: { "epic-1": { remove: false } } }}
  onItemMoved={(e) => persist(e)}
/>

Notes

  • Navigation: drag to pan (flick for momentum), pinch or ⌘/ctrl-wheel to zoom toward the cursor, plain wheel scrolls rows, shift-wheel pans. Toolbar + / − / Fit / Today and the imperative handle cover the same ground.
  • Bars: effective window is startAt ?? setAt → expireAt ?? (start + duration); no end ⇒ a milestone diamond. Color is status-tone fill (done=gray, blocked=red, active=urgency ramp imported from task-card); borderColor overrides per item; overdue adds a red end-cap.
  • Tooltip: lightweight by default; pass renderTooltip={(item) => <GanttFullCardTooltip item={item} />} to lazy-embed the full card (task-card only enters the bundle then).
  • Keyboard: the gutter is a WAI-ARIA tree — ↑/↓ move rows, ←/→ collapse/expand, Enter activates, Space toggles. Pan/zoom is gesture + toolbar.
  • SSR: pass now for deterministic first paint; otherwise the today line + urgency resolve after mount.

Features

  • v0.7.1 — `measureRows()` from the public `useGanttTimeline()` context re-measures for real; it was an empty body while the virtualizer's own `measure()` went unused
  • One bar per TaskItem: effectiveStart = startAt ?? setAt; effectiveEnd = expireAt ?? (start + duration); no end ⇒ milestone diamond
  • Collapsible WBS summary rows from children; summary bar spans min(child start) → max(child end)
  • Continuous zoom (pixels-per-time) with five named header buckets (hour · day · week · month · quarter) auto-selected with hysteresis; default week
  • Pan/swipe/zoom canvas: drag-pan with dominant-direction lock + flick momentum + boundary resistance; pinch + ⌘/ctrl-wheel focal-point zoom; +/−/fit toolbar; disableGestures opt-out
  • Filled bars colored by status tone: done=gray, blocked=red, active=time-urgency ramp (green→red) imported from task-card; per-item borderColor override; overdue red end-cap
  • Today 'now' line; SSR-safe first paint (now prop seeds; otherwise deferred to post-mount); colorRefreshIntervalMs tick
  • Frozen gutter tree: caret + assignee avatar + name + label dots + status badge; row virtualization via @tanstack/react-virtual (gutter mirrors the body scroll)
  • Read-only interactions: lightweight hover tooltip (renderTooltip override; GanttFullCardTooltip lazy-embeds the full card), click select + onTaskClick, collapse/expand, onViewportChange
  • WAI-ARIA tree on the gutter: role=tree/treeitem + aria-level/expanded/selected; arrow nav + Home/End + Enter + Space (conflict-free with viewport pan)
  • Imperative handle: scrollToDate / scrollToItem / scrollToToday / expandAll / collapseAll / setZoom / zoomBy / zoomToFit / addTask / deleteTask / editTask / beginRename
  • States: empty / loading (GanttTimelineSkeleton) / single / deep-nest / all-milestones / dense; weekend shading (opt-in, day/week zoom)
  • v0.2 editing (opt-in via `editable`, default off = byte-identical v1): drag-to-reschedule + edge-resize (snap to active unit, Alt = free) + milestone drag, with a live preview ghost
  • v0.2 CRUD: draw-on-canvas + gutter + create, delete (affordance / Delete key / context-menu), inline rename (double-click / F2), gutter reparent+reorder via @dnd-kit three-zone drops
  • v0.2 detail editing: right-click context-menu (Edit / Add / Status / Delete) + embedded <TaskCard editable> in a lazy overlay; edits fire onItemAdded/Removed/Moved/onFieldEdited/onStatusChanged + onChange(TaskItem[])
  • v0.2 permissions: reuses task-card's TaskPermissions matrix (default/byLevel/byItem/inherit) + canMove/Resize/Delete/CreateChild/EditItem predicates + onPermissionDenied; item.locked blocks all; summary bars non-manipulable for single-edit (group-move added in v0.3)
  • v0.3 group-move: drag a WBS summary bracket → rigidly shift the whole subtree by one snapped delta (Alt = free); atomic permission (summary + every descendant leaf movable, else the drag pans); fires per-leaf onFieldEdited/onTaskReschedule + one onChange; imperative shiftTaskGroup(id, deltaMs)
  • v0.4 Draw mode: a toolbar Draw toggle (editable only) gates draw-to-create — empty-row drag pans by default (off) and draws a new task only when on, ending the draw-vs-pan conflict; the whole gesture pipeline defers intent to the first move (a press classifies what's under it, then click→select, vertical→scroll, horizontal→resize/move/group-move/draw/pan); right-click menu fix — its portaled pointer/key events no longer bubble back into the canvas and swallow menu-item clicks
  • v0.5 double-click-to-create: double-click an empty row area → a lightweight quick-composer floats at the pointer (name autofocus + status pick, Enter creates a snapped sibling, More options → full editor); independent of Draw mode; `quickCompose` (default true) / `renderQuickComposer` override
  • v0.5 cross-surface clipboard: copy/cut/paste TaskItems through the shared `ilinxa/task` envelope (task-card/lib/clipboard) — ⌘/Ctrl+C·X·V (document-level, gated on focus + skipped over inputs) + right-click Copy/Cut; paste re-ids each subtree and lands as a sibling of the selection (dates preserved); interops with calendar / card-tree / tree
  • v0.5 Priority submenu: the right-click menu gains a Priority picker (when priorityOptions provided); changePriority mutates + onChange only (parity with calendar — 'priority' is not a typed field event)
  • v0.7 barrel completeness: `GanttContextValue` (the exported `useGanttTimeline()` hook's return type) and `GanttRenderItem` (the element type of its `renderItems`) are now importable from the package root. Type-only, additive.

Tags

gantt-timelinegantttimelinescheduleroadmapprojectwbstodo

Dependencies

shadcn primitives: avatar, badge, button, context-menu, input, separator, skeleton
npm peer deps: lucide-react@^1.11.0, @tanstack/react-virtual@^3.13.24, @dnd-kit/core@^6.3.1
internal: task-card