SaaS & Ops

Roadmap Timeline

A quarters-by-initiatives roadmap with gantt-style bars that wipe in on mount, a now marker, and inner progress fills on active work.

Install

npx shadcn@latest add @paragon/roadmap-timeline

roadmap-timeline.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";

export type RoadmapStatus = "shipped" | "active" | "planned";

export interface RoadmapInitiative {
  id: string;
  name: string;
  team?: string;
  /** ISO date (YYYY-MM-DD) the bar starts on. */
  start: string;
  /** ISO date (YYYY-MM-DD) the bar ends on. */
  end: string;
  status?: RoadmapStatus;
  /** 0–100 completion; renders an inner fill on active bars. */
  progress?: number;
}

export interface RoadmapPeriod {
  /** Header label, e.g. "Q1". */
  label: string;
  /** ISO date the period starts on (inclusive). */
  start: string;
  /** ISO date the period ends on (exclusive). */
  end: string;
}

export interface RoadmapTimelineProps
  extends Omit<React.ComponentProps<"div">, "onSelect"> {
  /** Period columns along the axis, e.g. quarters. */
  periods?: RoadmapPeriod[];
  initiatives?: RoadmapInitiative[];
  /** ISO date to mark with a "now" line. Omit to hide it. */
  today?: string;
  /** Width of the leading label rail, in rem. */
  labelWidth?: number;
  /** Renders the final state immediately, no wipe-in. */
  static?: boolean;
  onSelect?: (initiative: RoadmapInitiative) => void;
}

const statusMeta: Record<
  RoadmapStatus,
  { bar: string; text: string; label: string }
> = {
  shipped: {
    bar: "bg-success/70",
    text: "text-success-foreground",
    label: "Shipped",
  },
  active: { bar: "bg-primary", text: "text-primary-foreground", label: "Active" },
  planned: {
    bar: "bg-muted-foreground/25",
    text: "text-foreground",
    label: "Planned",
  },
};

const STATUS_ORDER: RoadmapStatus[] = ["shipped", "active", "planned"];

const DEFAULT_PERIODS: RoadmapPeriod[] = [
  { label: "Q1", start: "2026-01-01", end: "2026-04-01" },
  { label: "Q2", start: "2026-04-01", end: "2026-07-01" },
  { label: "Q3", start: "2026-07-01", end: "2026-10-01" },
  { label: "Q4", start: "2026-10-01", end: "2027-01-01" },
];

const DEFAULT_INITIATIVES: RoadmapInitiative[] = [
  { id: "sso", name: "Enterprise SSO & SCIM", team: "Identity", start: "2026-01-08", end: "2026-05-20", status: "shipped" },
  { id: "audit", name: "Audit log & retention", team: "Security", start: "2026-04-01", end: "2026-06-24", status: "active", progress: 62 },
  { id: "mobile", name: "Mobile app v2", team: "Apps", start: "2026-05-05", end: "2026-09-15", status: "active", progress: 34 },
  { id: "billing", name: "Usage-based billing", team: "Growth", start: "2026-07-14", end: "2026-11-30", status: "planned" },
  { id: "api", name: "Public API GA", team: "Platform", start: "2026-10-06", end: "2026-12-18", status: "planned" },
];

/** Parse an ISO date to a UTC millisecond timestamp (date-only, stable). */
function toMs(iso: string): number {
  const [y, m, d] = iso.split("-").map(Number);
  return Date.UTC(y, (m ?? 1) - 1, d ?? 1);
}

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

function fmtDate(iso: string): string {
  const [, m, d] = iso.split("-").map(Number);
  return `${MONTHS[(m ?? 1) - 1]} ${d}`;
}

/**
 * A roadmap timeline: initiatives laid out as gantt bars on a single shared
 * time scale. Every bar's left edge and width, each period gridline, and the
 * "now" marker are derived from the same `x = (date - rangeStart) / span`
 * projection, so nothing is eyeballed — they align exactly. Rows reveal with
 * the house enter language (opacity + translateY + blur, staggered) on first
 * view, and each bar carries a tooltip with its dates and owner. A hoverable
 * legend dims non-matching statuses. Collapses to a plain fade under reduced
 * motion; correct in both themes at zero props.
 */
export function RoadmapTimeline({
  periods = DEFAULT_PERIODS,
  initiatives = DEFAULT_INITIATIVES,
  today = "2026-06-15",
  labelWidth = 10,
  static: isStatic = false,
  className,
  style,
  onSelect,
  ...props
}: RoadmapTimelineProps) {
  const reduced = useReducedMotion();
  const rootRef = React.useRef<HTMLDivElement>(null);
  const inView = useInView(rootRef, { once: true, margin: "0px 0px -40px 0px" });
  const shown = isStatic || reduced || inView;
  const [active, setActive] = React.useState<RoadmapStatus | null>(null);

  // One shared scale for the whole track: [rangeStart, rangeEnd] → [0, 1].
  const rangeStart = periods.length ? toMs(periods[0].start) : 0;
  const rangeEnd = periods.length ? toMs(periods[periods.length - 1].end) : 1;
  const span = Math.max(1, rangeEnd - rangeStart);
  /** Map an ISO date to a fraction (0–1) of the track width. */
  const scale = React.useCallback(
    (iso: string) => (toMs(iso) - rangeStart) / span,
    [rangeStart, span],
  );

  const pct = (n: number) => `${(n * 100).toFixed(4)}%`;
  const todayFrac = today ? scale(today) : null;
  const todayVisible = todayFrac !== null && todayFrac >= 0 && todayFrac <= 1;

  const railStyle = { "--rail": `${labelWidth}rem` } as React.CSSProperties;

  return (
    <TooltipProvider>
      <div
        ref={rootRef}
        data-slot="roadmap-timeline"
        className={cn(
          "w-full overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
          className,
        )}
        style={{ ...railStyle, ...style }}
        {...props}
      >
        {/* Period header. Each label sits over its period's mid-fraction. */}
        <div className="relative flex border-b border-border">
          <div
            className="shrink-0 px-3 py-2 text-xs font-medium text-muted-foreground"
            style={{ width: "var(--rail)" }}
          >
            Initiative
          </div>
          <div className="relative min-w-0 flex-1">
            {periods.map((period, i) => {
              const left = scale(period.start);
              return (
                <div
                  key={period.label}
                  className={cn(
                    "absolute top-0 bottom-0 flex items-center px-3 text-xs font-medium text-muted-foreground",
                    i > 0 && "border-l border-border",
                  )}
                  style={{
                    left: pct(left),
                    width: pct(scale(period.end) - left),
                  }}
                >
                  {period.label}
                </div>
              );
            })}
            {/* Header needs intrinsic height even though children are absolute. */}
            <div className="px-3 py-2 text-xs font-medium opacity-0" aria-hidden>
              {periods[0]?.label ?? "—"}
            </div>
          </div>
        </div>

        <div className="relative">
          {/* Period gridlines spanning all rows — same scale as the header. */}
          <div
            aria-hidden
            className="pointer-events-none absolute inset-y-0 right-0"
            style={{ left: "var(--rail)" }}
          >
            {periods.slice(1).map((period) => (
              <span
                key={period.label}
                className="absolute inset-y-0 w-px bg-border/60"
                style={{ left: pct(scale(period.start)) }}
              />
            ))}
            {/* Now marker — same projection as everything else. */}
            {todayVisible && (
              <span
                className="absolute inset-y-0 z-10 w-px bg-primary/50"
                style={{ left: pct(todayFrac!) }}
              >
                <span className="absolute -top-px left-1/2 size-1.5 -translate-x-1/2 rounded-full bg-primary" />
              </span>
            )}
          </div>

          <div className="divide-y divide-border">
            {initiatives.map((item, row) => {
              const meta = statusMeta[item.status ?? "planned"];
              const left = scale(item.start);
              const width = scale(item.end) - left;
              const dimmed = active !== null && item.status !== active;
              return (
                <div
                  key={item.id}
                  className="relative flex items-center transition-opacity duration-(--duration-base) ease-out motion-reduce:transition-none"
                  style={{
                    opacity: shown ? (dimmed ? 0.4 : 1) : 0,
                    transform: shown ? "translateY(0)" : "translateY(12px)",
                    filter: shown ? "blur(0)" : "blur(4px)",
                    transition: shown
                      ? "opacity var(--duration-base) var(--ease-out), transform var(--duration-slow) var(--ease-out), filter var(--duration-slow) var(--ease-out)"
                      : "none",
                    transitionDelay: shown && !reduced && !isStatic ? `${row * 55}ms` : "0ms",
                  }}
                >
                  <div
                    className="min-w-0 shrink-0 px-3 py-2.5"
                    style={{ width: "var(--rail)" }}
                  >
                    <Tooltip>
                      <TooltipTrigger asChild>
                        <button
                          type="button"
                          onClick={() => onSelect?.(item)}
                          className="block max-w-full truncate text-left text-sm font-medium outline-none rounded-sm focus-visible:ring-2 focus-visible:ring-ring"
                        >
                          {item.name}
                        </button>
                      </TooltipTrigger>
                      <TooltipContent>{item.name}</TooltipContent>
                    </Tooltip>
                    {item.team && (
                      <p className="truncate text-[11px] text-muted-foreground">
                        {item.team}
                      </p>
                    )}
                  </div>

                  <div className="relative min-w-0 flex-1 py-2">
                    <Tooltip>
                      <TooltipTrigger asChild>
                        <button
                          type="button"
                          onClick={() => onSelect?.(item)}
                          aria-label={`${item.name}: ${fmtDate(item.start)} to ${fmtDate(item.end)}`}
                          className={cn(
                            "absolute inset-y-2 flex items-center overflow-hidden rounded-md px-2.5 outline-none",
                            "transition-[filter,scale] duration-(--duration-fast) ease-out",
                            "hover:brightness-105 hover:contrast-105",
                            "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-card",
                            !isStatic && "active:scale-[0.99]",
                            meta.bar,
                          )}
                          style={{
                            left: pct(left),
                            width: pct(width),
                            clipPath: shown ? "inset(0 0 0 0)" : "inset(0 100% 0 0)",
                            transition:
                              shown && !reduced && !isStatic
                                ? `clip-path var(--duration-slow) var(--ease-out) ${row * 55 + 80}ms`
                                : undefined,
                          }}
                        >
                          {item.status === "active" &&
                            typeof item.progress === "number" && (
                              <span
                                aria-hidden
                                className="absolute inset-y-0 left-0 bg-primary-foreground/15"
                                style={{ width: `${item.progress}%` }}
                              />
                            )}
                          <span
                            className={cn(
                              "relative truncate text-[11px] font-medium tabular-nums",
                              meta.text,
                            )}
                          >
                            {item.status === "active" &&
                            typeof item.progress === "number"
                              ? `${item.progress}%`
                              : meta.label}
                          </span>
                        </button>
                      </TooltipTrigger>
                      <TooltipContent>
                        <span className="tabular-nums">
                          {fmtDate(item.start)}{fmtDate(item.end)}
                        </span>
                        {item.team && <span> · {item.team}</span>}
                      </TooltipContent>
                    </Tooltip>
                  </div>
                </div>
              );
            })}
          </div>
        </div>

        {/* Hoverable legend — dims non-matching statuses across the chart. */}
        <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t border-border px-3 py-2.5">
          {STATUS_ORDER.map((status) => (
            <button
              key={status}
              type="button"
              onMouseEnter={() => setActive(status)}
              onMouseLeave={() => setActive(null)}
              onFocus={() => setActive(status)}
              onBlur={() => setActive(null)}
              className={cn(
                "flex items-center gap-1.5 rounded-sm text-xs text-muted-foreground outline-none",
                "transition-opacity duration-(--duration-fast) ease-out",
                "focus-visible:ring-2 focus-visible:ring-ring",
                active !== null && active !== status && "opacity-40",
              )}
            >
              <span
                aria-hidden
                className={cn("size-2 rounded-[3px]", statusMeta[status].bar)}
              />
              {statusMeta[status].label}
            </button>
          ))}
        </div>
      </div>
    </TooltipProvider>
  );
}