Parallel Timeline

Multi-track timeline with shared time axis, BCE/CE event markers, and optional era bands for comparative history.

Report a bug

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.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { type ComponentPropsWithoutRef, 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, index) => ( <span className="absolute top-1 -translate-x-1/2" key={`${tick.label}-${index.toString()}`} 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 = ({ ref, ...props }: ParallelTimelineProps & { ref?: React.Ref<HTMLElement> }) => { 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";

Dependencies

  • @vllnt/ui@^0.3.0