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
Design System Audit
Component Library v2
iOS App Redesign
Warren Integration
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Sep
Oct
Nov
Dec

Layout spec

ElementSpec
Label column228 px fixed, sticky on horizontal scroll, --surface background
Month header32 px, sticky top, 10–11 px semibold uppercase, --text-tertiary
Zoom levelsYear = 2.5 px/day, Quarter = 5 px/day, Month = 12 px/day
Grid fillsAlternating --surface / --surface-raised/0.4 month strips
Month dividers0.5 px --border-subtle
Project bar20 px tall, 4 px radius; track at 18% opacity, logged fill at 65%
Assignment sub-row36 px row, 14 px tall pill at 45% opacity, 16 px left indent
Today line2 px solid --accent capsule
Milestone10×10 diamond (45° rotated rect), colour-coded, tappable
Dependency arrowOrthogonal 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>
  );
}