New
Gantt / Timeline
A horizontal timeline chart for visualising project schedules, milestones, and resource assignments across time. Reference implementation from Warren.
Demo
Quarter zoom (5 px/day). Scroll horizontally to explore the full year.
Timeline — 2026
YearQuarterMonth
Layout spec
| Element | Spec |
|---|---|
| Label column | 228 px fixed, sticky on horizontal scroll, --surface background |
| Month header | 32 px, sticky top, 10–11 px semibold uppercase, --text-tertiary |
| Zoom levels | Year = 2.5 px/day, Quarter = 5 px/day, Month = 12 px/day |
| Grid fills | Alternating --surface / --surface-raised/0.4 month strips |
| Month dividers | 0.5 px --border-subtle |
| Project bar | 20 px tall, 4 px radius; track at 18% opacity, logged fill at 65% |
| Assignment sub-row | 36 px row, 14 px tall pill at 45% opacity, 16 px left indent |
| Today line | 2 px solid --accent capsule |
| Milestone | 10×10 diamond (45° rotated rect), colour-coded, tappable |
| Dependency arrow | Orthogonal elbow path, dashed [5,3], red at isAtRisk: true |
Implementation
GanttChart.tsx
tsx
"use client";
import { useRef, useState } from "react";
// Zoom presets: pixels per day
const ZOOM = { year: 2.5, quarter: 5, month: 12 } as const;
type ZoomLevel = keyof typeof ZOOM;
interface GanttProject {
id: string;
name: string;
start: Date;
end: Date;
colour: string;
logged?: number; // 0–1, logged hours fraction
overdue?: boolean;
assignments?: Array<{ name: string; start: Date; end: Date }>;
}
const LABEL_W = 228;
export function GanttChart({ projects }: { projects: GanttProject[] }) {
const [zoom, setZoom] = useState<ZoomLevel>("quarter");
const chartRef = useRef<HTMLDivElement>(null);
const ppd = ZOOM[zoom];
const today = new Date();
const rangeStart = new Date(today.getFullYear(), 0, 1); // Jan 1 of current year
const rangeEnd = new Date(today.getFullYear(), 11, 31);
const totalDays = (rangeEnd.getTime() - rangeStart.getTime()) / 86_400_000;
const chartWidth = totalDays * ppd;
const todayX = ((today.getTime() - rangeStart.getTime()) / 86_400_000) * ppd;
function dateToX(date: Date) {
return ((date.getTime() - rangeStart.getTime()) / 86_400_000) * ppd;
}
// Generate month headers
const months: Array<{ label: string; x: number; width: number }> = [];
for (let m = 0; m < 12; m++) {
const ms = new Date(today.getFullYear(), m, 1);
const me = new Date(today.getFullYear(), m + 1, 0);
months.push({
label: ms.toLocaleString("default", { month: "short" }),
x: dateToX(ms),
width: ((me.getTime() - ms.getTime()) / 86_400_000) * ppd,
});
}
return (
<div style={{ fontFamily: "inherit" }}>
{/* Toolbar */}
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 12,
padding: "0 4px",
}}>
<span style={{ fontSize: 13, fontWeight: 600 }}>Timeline</span>
<div style={{ display: "flex", gap: 2, padding: 3, borderRadius: 8, background: "rgb(var(--surface-raised))", border: "1px solid rgb(var(--border))" }}>
{(["year", "quarter", "month"] as ZoomLevel[]).map((z) => (
<button
key={z}
onClick={() => setZoom(z)}
style={{
padding: "4px 12px",
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
border: "none",
cursor: "pointer",
background: zoom === z ? "rgb(var(--accent))" : "transparent",
color: zoom === z ? "#fff" : "rgb(var(--text-secondary))",
transition: "background 0.15s, color 0.15s",
}}
>
{z.charAt(0).toUpperCase() + z.slice(1)}
</button>
))}
</div>
</div>
<div style={{ display: "flex", overflow: "hidden", borderRadius: 12, border: "1px solid rgb(var(--border))" }}>
{/* Label column */}
<div style={{ width: LABEL_W, flexShrink: 0, borderRight: "1px solid rgb(var(--border))", background: "rgb(var(--surface))" }}>
{/* Month header spacer */}
<div style={{ height: 32, borderBottom: "1px solid rgb(var(--border))" }} />
{/* Project labels */}
{projects.map((proj) => (
<div key={proj.id}>
<div style={{
height: 44,
display: "flex",
alignItems: "center",
padding: "0 14px",
gap: 8,
borderBottom: "1px solid rgb(var(--border-subtle))",
}}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: proj.colour, flexShrink: 0 }} />
<span style={{ fontSize: 12, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{proj.name}
</span>
</div>
{proj.assignments?.map((a) => (
<div key={a.name} style={{
height: 36,
display: "flex",
alignItems: "center",
padding: "0 14px 0 30px",
gap: 6,
borderBottom: "1px solid rgb(var(--border-subtle))",
}}>
<span style={{ fontSize: 11, color: "rgb(var(--text-tertiary))" }}>{a.name}</span>
</div>
))}
</div>
))}
</div>
{/* Scrollable chart */}
<div ref={chartRef} style={{ flex: 1, overflowX: "auto", position: "relative", background: "rgb(var(--canvas, var(--surface)))" }}>
<div style={{ width: chartWidth, position: "relative" }}>
{/* Month header row */}
<div style={{ display: "flex", height: 32, borderBottom: "1px solid rgb(var(--border))", position: "sticky", top: 0, zIndex: 1, background: "rgb(var(--surface))" }}>
{months.map((m, i) => (
<div key={i} style={{
width: m.width,
flexShrink: 0,
display: "flex",
alignItems: "center",
paddingLeft: 8,
fontSize: 11,
fontWeight: 600,
letterSpacing: "0.05em",
color: "rgb(var(--text-tertiary))",
borderRight: "1px solid rgb(var(--border-subtle))",
}}>
{m.label}
</div>
))}
</div>
{/* Alternating month fills */}
<div style={{ position: "absolute", top: 32, left: 0, right: 0, bottom: 0, pointerEvents: "none" }}>
{months.map((m, i) => (
i % 2 === 1 && (
<div key={i} style={{
position: "absolute",
left: m.x,
top: 0,
width: m.width,
bottom: 0,
background: "rgb(var(--surface-raised) / 0.5)",
}} />
)
))}
</div>
{/* Today line */}
<div style={{
position: "absolute",
left: todayX,
top: 32,
bottom: 0,
width: 2,
background: "rgb(var(--accent))",
borderRadius: 1,
pointerEvents: "none",
}} />
{/* Project rows */}
{projects.map((proj) => {
const x = dateToX(proj.start);
const w = Math.max(dateToX(proj.end) - x, 4);
return (
<div key={proj.id}>
<div style={{ height: 44, display: "flex", alignItems: "center", borderBottom: "1px solid rgb(var(--border-subtle))", position: "relative" }}>
{/* Project bar */}
<div style={{
position: "absolute",
left: x,
width: w,
height: 20,
borderRadius: 4,
background: proj.colour + "30",
border: proj.overdue ? `1.5px solid ${proj.colour}` : "none",
overflow: "hidden",
}}>
{/* Logged fill */}
<div style={{
position: "absolute",
left: 0,
top: 0,
height: "100%",
width: `${(proj.logged ?? 0.65) * 100}%`,
background: proj.colour + "80",
borderRadius: "4px 0 0 4px",
}} />
</div>
</div>
{proj.assignments?.map((a) => {
const ax = dateToX(a.start);
const aw = Math.max(dateToX(a.end) - ax, 4);
return (
<div key={a.name} style={{ height: 36, display: "flex", alignItems: "center", borderBottom: "1px solid rgb(var(--border-subtle))", position: "relative" }}>
<div style={{
position: "absolute",
left: ax,
width: aw,
height: 14,
borderRadius: 3,
background: proj.colour + "45",
}} />
</div>
);
})}
</div>
);
})}
</div>
</div>
</div>
</div>
);
}