Parallel Timeline
Multi-track timeline with shared time axis, BCE/CE event markers, and optional era bands for comparative history.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/parallel-timeline.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook4 stories available:
Code
"use client";
import {
type ComponentPropsWithoutRef,
forwardRef,
type ReactNode,
useMemo,
} from "react";
import { cn } from "../../lib/utils";
const DEFAULT_TICK_COUNT = 8;
/**
* Color theme for a {@link ParallelTimelineTrack} accent strip and
* {@link ParallelTimelineEvent} markers.
*
* @public
*/
export type ParallelTimelineColor =
| "amber"
| "blue"
| "emerald"
| "neutral"
| "purple"
| "red"
| "rose";
const COLOR_CLASSES: Record<
ParallelTimelineColor,
{ accent: string; chip: string; marker: string }
> = {
amber: {
accent: "bg-amber-500",
chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
marker: "border-amber-500 bg-amber-500",
},
blue: {
accent: "bg-blue-500",
chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
marker: "border-blue-500 bg-blue-500",
},
emerald: {
accent: "bg-emerald-500",
chip: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
marker: "border-emerald-500 bg-emerald-500",
},
neutral: {
accent: "bg-muted-foreground/40",
chip: "bg-muted text-muted-foreground",
marker: "border-muted-foreground bg-muted-foreground",
},
purple: {
accent: "bg-purple-500",
chip: "bg-purple-500/15 text-purple-700 dark:text-purple-300",
marker: "border-purple-500 bg-purple-500",
},
red: {
accent: "bg-red-500",
chip: "bg-red-500/15 text-red-700 dark:text-red-300",
marker: "border-red-500 bg-red-500",
},
rose: {
accent: "bg-rose-500",
chip: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
marker: "border-rose-500 bg-rose-500",
},
};
/**
* One event marker on a {@link ParallelTimelineTrack}.
*
* @public
*/
export type ParallelTimelineEvent = {
/** Stable identifier within the track. */
id: string;
/** Optional secondary line (date detail, place, etc.). */
meta?: ReactNode;
/** Event title. */
title: ReactNode;
/** Year. Negative for BCE / positive for CE. */
year: number;
};
/**
* Single horizontal track inside a {@link ParallelTimeline}.
*
* @public
*/
export type ParallelTimelineTrack = {
/** Color theme. Defaults to `"neutral"`. */
color?: ParallelTimelineColor;
/** Event markers. */
events: ParallelTimelineEvent[];
/** Stable identifier. */
id: string;
/** Display name. */
name: ReactNode;
/** Optional region label rendered next to the name. */
region?: ReactNode;
};
/**
* Background era band rendered behind every track.
*
* @public
*/
export type ParallelTimelineEra = {
/** Color theme — drives the band tint. Defaults to `"neutral"`. */
color?: ParallelTimelineColor;
/** End year (inclusive). */
end: number;
/** Stable identifier. */
id: string;
/** Display name. */
name: ReactNode;
/** Start year (inclusive). */
start: number;
};
/**
* Localizable strings.
*
* @public
*/
export type ParallelTimelineLabels = {
/** Aria-label for the timeline region. Defaults to `"Parallel timeline"`. */
region?: string;
};
const DEFAULT_LABELS = {
region: "Parallel timeline",
} as const satisfies Required<ParallelTimelineLabels>;
/**
* Props for {@link ParallelTimeline}.
*
* @public
*/
export type ParallelTimelineProps = {
/** End year (positive for CE). */
endYear: number;
/** Optional background era bands shared across tracks. */
eras?: ParallelTimelineEra[];
/** Localizable strings. */
labels?: ParallelTimelineLabels;
/** Start year (negative for BCE). */
startYear: number;
/** Number of axis ticks to render. Defaults to `8`. */
tickCount?: number;
/** Track list, rendered in order. */
tracks: ParallelTimelineTrack[];
} & ComponentPropsWithoutRef<"section">;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function formatYear(year: number): string {
if (year < 0) return `${Math.abs(year).toString()} BCE`;
return `${year.toString()} CE`;
}
function yearToPercent(year: number, start: number, end: number): number {
const span = end - start;
if (span <= 0) return 0;
return clamp(((year - start) / span) * 100, 0, 100);
}
function buildTicks(
start: number,
end: number,
count: number,
): { label: string; offset: number }[] {
const safeCount = Math.max(2, count);
const span = end - start;
if (span <= 0) return [];
const step = span / (safeCount - 1);
return Array.from({ length: safeCount }).map((_, index) => {
const year = Math.round(start + step * index);
return {
label: formatYear(year),
offset: yearToPercent(year, start, end),
};
});
}
type AxisProps = {
ticks: { label: string; offset: number }[];
};
function Axis({ ticks }: AxisProps): ReactNode {
return (
<div
aria-hidden="true"
className="relative h-7 border-b border-border text-[10px] font-medium uppercase tracking-wide text-muted-foreground"
>
{ticks.map((tick) => (
<span
className="absolute top-1 -translate-x-1/2"
key={tick.label}
style={{ left: `${tick.offset.toString()}%` }}
>
{tick.label}
</span>
))}
</div>
);
}
type EraBandsProps = {
endYear: number;
eras: ParallelTimelineEra[];
startYear: number;
};
function EraBands({ endYear, eras, startYear }: EraBandsProps): ReactNode {
if (eras.length === 0) return null;
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
{eras.map((era) => {
const left = yearToPercent(era.start, startYear, endYear);
const right = yearToPercent(era.end, startYear, endYear);
const width = Math.max(0, right - left);
if (width <= 0) return null;
const palette = COLOR_CLASSES[era.color ?? "neutral"];
return (
<div
className={cn("absolute inset-y-0", palette.chip)}
data-era-id={era.id}
key={era.id}
style={{
left: `${left.toString()}%`,
width: `${width.toString()}%`,
}}
/>
);
})}
</div>
);
}
type EventMarkerProps = {
color: ParallelTimelineColor;
endYear: number;
event: ParallelTimelineEvent;
startYear: number;
};
function EventMarker({
color,
endYear,
event,
startYear,
}: EventMarkerProps): ReactNode {
if (event.year < startYear || event.year > endYear) return null;
const left = yearToPercent(event.year, startYear, endYear);
const palette = COLOR_CLASSES[color];
const titleText = typeof event.title === "string" ? event.title : "";
const ariaLabel = titleText
? `${titleText}, ${formatYear(event.year)}`
: undefined;
return (
<div
aria-label={ariaLabel}
className="absolute top-1/2 z-10 -translate-x-1/2 -translate-y-1/2"
data-event-id={event.id}
data-event-year={event.year}
style={{ left: `${left.toString()}%` }}
>
<div
aria-hidden="true"
className={cn(
"size-3 rounded-full border-2 ring-2 ring-background",
palette.marker,
)}
/>
<div className="absolute left-1/2 top-4 w-40 -translate-x-1/2 text-center">
<p className="truncate text-xs font-medium text-foreground">
{event.title}
</p>
<p className="truncate text-[10px] text-muted-foreground">
{formatYear(event.year)}
{event.meta ? <span> · {event.meta}</span> : null}
</p>
</div>
</div>
);
}
type TrackRowProps = {
endYear: number;
startYear: number;
track: ParallelTimelineTrack;
};
function TrackRow({ endYear, startYear, track }: TrackRowProps): ReactNode {
const color = track.color ?? "neutral";
const palette = COLOR_CLASSES[color];
return (
<div className="relative flex items-stretch gap-3 border-t border-border first:border-t-0">
<div className="flex w-32 shrink-0 flex-col gap-1 border-r border-border bg-muted/20 p-3">
<span
aria-hidden="true"
className={cn("h-1 w-8 rounded-full", palette.accent)}
/>
<p className="text-sm font-semibold tracking-tight text-foreground">
{track.name}
</p>
{track.region ? (
<p className="text-xs text-muted-foreground">{track.region}</p>
) : null}
</div>
<div
aria-label={typeof track.name === "string" ? track.name : undefined}
className="relative h-16 min-w-0 flex-1"
data-track-id={track.id}
>
<div className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-border" />
{track.events.map((event) => (
<EventMarker
color={color}
endYear={endYear}
event={event}
key={event.id}
startYear={startYear}
/>
))}
</div>
</div>
);
}
/**
* Multi-track timeline with a shared time axis. Renders each track as a
* horizontal lane with event markers positioned by year. Negative years
* render as `BCE`, positive as `CE`. Background era bands span every
* track when the consumer passes `eras`.
*
* Cross-track connectors, synchronized pan/zoom, collapsible tracks,
* and click-to-compare are intentionally **out of scope** — drive them
* from consumer code via the data slots.
*
* @example
* ```tsx
* <ParallelTimeline
* startYear={-500}
* endYear={500}
* eras={[{ id: "antiquity", name: "Antiquity", start: -500, end: 500, color: "neutral" }]}
* tracks={[
* {
* id: "rome",
* name: "Rome",
* color: "red",
* events: [
* { id: "augustus", year: -27, title: "Augustus becomes Emperor" },
* { id: "fall", year: 476, title: "Fall of Western Rome" },
* ],
* },
* {
* id: "china",
* name: "China",
* color: "amber",
* events: [
* { id: "qin", year: -221, title: "Qin unifies China" },
* { id: "han-end", year: 220, title: "End of Han Dynasty" },
* ],
* },
* ]}
* />
* ```
*
* @public
*/
export const ParallelTimeline = forwardRef<HTMLElement, ParallelTimelineProps>(
(props, ref) => {
const {
className,
endYear,
eras = [],
labels,
startYear,
tickCount = DEFAULT_TICK_COUNT,
tracks,
...rest
} = props;
const resolvedLabels = useMemo(
() => ({ ...DEFAULT_LABELS, ...labels }),
[labels],
);
const ticks = useMemo(
() => buildTicks(startYear, endYear, tickCount),
[endYear, startYear, tickCount],
);
return (
<section
aria-label={resolvedLabels.region}
className={cn(
"flex w-full flex-col overflow-x-auto rounded-2xl border bg-background text-foreground",
className,
)}
ref={ref}
{...rest}
>
<div className="flex items-stretch gap-3 border-b border-border">
<div aria-hidden="true" className="w-32 shrink-0" />
<Axis ticks={ticks} />
</div>
<div className="relative">
<EraBands endYear={endYear} eras={eras} startYear={startYear} />
{tracks.map((track) => (
<TrackRow
endYear={endYear}
key={track.id}
startYear={startYear}
track={track}
/>
))}
</div>
</section>
);
},
);
ParallelTimeline.displayName = "ParallelTimeline";
typescript