Gantt Timeline
alphav0.7.1Editable Gantt timeline — per-task bars, collapsible summary rows, milestone diamonds, continuous zoom from hours to quarters, and a today line.
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
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/gantt-timelineAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/gantt-timeline-fixturesCLI 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
Demo source
"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 / Todayand 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);borderColoroverrides 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
nowfor 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.