A quarters-by-initiatives roadmap with gantt-style bars that wipe in on mount, a now marker, and inner progress fills on active work.
npx shadcn@latest add @paragon/roadmap-timeline"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>
);
}