Map Timeline

Standalone SVG map + time slider — era polygons and year-pinned events appear as the user scrubs the timeline.

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/map-timeline.json
bash

Storybook

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

View in Storybook

3 stories available:

Code

"use client";

import {
  type ChangeEvent,
  type ComponentPropsWithoutRef,
  createContext,
  forwardRef,
  type ReactNode,
  useCallback,
  useContext,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
} from "react";

import { cn } from "../../lib/utils";

const VIEWBOX_WIDTH = 1000;
const VIEWBOX_HEIGHT = 500;

/**
 * Geographic coordinate `[longitude, latitude]`.
 *
 * @public
 */
export type GeoPosition = [number, number];

/**
 * Color theme for layers and event markers.
 *
 * @public
 */
export type MapTimelineColor =
  | "amber"
  | "blue"
  | "emerald"
  | "purple"
  | "red"
  | "rose";

const PALETTE: Record<
  MapTimelineColor,
  { dot: string; fill: string; stroke: string }
> = {
  amber: {
    dot: "fill-amber-500",
    fill: "rgba(245, 158, 11, 0.25)",
    stroke: "#b45309",
  },
  blue: {
    dot: "fill-blue-500",
    fill: "rgba(59, 130, 246, 0.25)",
    stroke: "#1d4ed8",
  },
  emerald: {
    dot: "fill-emerald-500",
    fill: "rgba(16, 185, 129, 0.25)",
    stroke: "#047857",
  },
  purple: {
    dot: "fill-purple-500",
    fill: "rgba(168, 85, 247, 0.25)",
    stroke: "#7c3aed",
  },
  red: {
    dot: "fill-red-500",
    fill: "rgba(239, 68, 68, 0.25)",
    stroke: "#b91c1c",
  },
  rose: {
    dot: "fill-rose-500",
    fill: "rgba(244, 63, 94, 0.25)",
    stroke: "#be123c",
  },
};

/**
 * Localizable strings.
 *
 * @public
 */
export type MapTimelineLabels = {
  /** Pause-button label. Defaults to `"Pause"`. */
  pause?: string;
  /** Play-button label. Defaults to `"Play"`. */
  play?: string;
  /** Aria-label for the map region. Defaults to `"Map timeline"`. */
  region?: string;
  /** Aria-label for the timeline slider. Defaults to `"Year"`. */
  slider?: string;
};

const DEFAULT_LABELS = {
  pause: "Pause",
  play: "Play",
  region: "Map timeline",
  slider: "Year",
} as const satisfies Required<MapTimelineLabels>;

type Ctx = {
  endYear: number;
  isPlaying: boolean;
  labels: Required<MapTimelineLabels>;
  setIsPlaying: (next: boolean) => void;
  setYear: (next: number) => void;
  speed: number;
  startYear: number;
  year: number;
};

const TimelineContext = createContext<Ctx | null>(null);

function useTimelineContext(): Ctx {
  const ctx = useContext(TimelineContext);
  if (!ctx) {
    throw new Error("MapTimeline subcomponent used outside its root.");
  }
  return ctx;
}

function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(value, min), max);
}

function projectEquirectangular(position: GeoPosition): {
  x: number;
  y: number;
} {
  const [lng, lat] = position;
  return {
    x: ((lng + 180) / 360) * VIEWBOX_WIDTH,
    y: ((90 - lat) / 180) * VIEWBOX_HEIGHT,
  };
}

function formatYear(year: number): string {
  if (year < 0) return `${Math.abs(year).toString()} BCE`;
  return `${year.toString()} CE`;
}

function ringToPath(ring: GeoPosition[]): string {
  return ring
    .map((position, index) => {
      const projected = projectEquirectangular(position);
      return `${index === 0 ? "M" : "L"}${projected.x.toString()},${projected.y.toString()}`;
    })
    .join(" ");
}

/**
 * GeoJSON-style polygon for a {@link MapTimelineLayer}. Pass either a
 * `polygon` (single ring) or a `polygons` array (multi-polygon).
 *
 * @public
 */
export type MapTimelineGeometry =
  | { polygon: GeoPosition[]; polygons?: never; type: "polygon" }
  | { polygon?: never; polygons: GeoPosition[][]; type: "multipolygon" };

/**
 * Props for {@link MapTimelineLayer}.
 *
 * @public
 */
export type MapTimelineLayerProps = {
  /** Color theme. Defaults to `"blue"`. */
  color?: MapTimelineColor;
  /** Year (inclusive) when the layer disappears. */
  endYear: number;
  /** Polygon geometry. */
  geometry: MapTimelineGeometry;
  /** Stable id used for analytics + React keys. */
  id?: string;
  /** Display label rendered in the centroid when visible. */
  label?: ReactNode;
  /** Year (inclusive) when the layer first appears. */
  startYear: number;
} & Omit<ComponentPropsWithoutRef<"g">, "id">;

function geometryRings(geometry: MapTimelineGeometry): GeoPosition[][] {
  if (geometry.type === "polygon") return [geometry.polygon];
  return geometry.polygons;
}

function ringCentroid(ring: GeoPosition[]): { x: number; y: number } {
  if (ring.length === 0) return { x: 0, y: 0 };
  const total = ring.reduce<{ x: number; y: number }>(
    (accumulator, position) => {
      const projected = projectEquirectangular(position);
      return { x: accumulator.x + projected.x, y: accumulator.y + projected.y };
    },
    { x: 0, y: 0 },
  );
  return { x: total.x / ring.length, y: total.y / ring.length };
}

/**
 * Geographic layer pinned to a year window.
 *
 * @public
 */
export const MapTimelineLayer = forwardRef<SVGGElement, MapTimelineLayerProps>(
  (props, ref) => {
    const {
      color = "blue",
      endYear,
      geometry,
      id,
      label,
      startYear,
      ...rest
    } = props;
    const { year } = useTimelineContext();
    if (year < startYear || year > endYear) return null;
    const palette = PALETTE[color];
    const rings = geometryRings(geometry);
    const centroid = rings[0] ? ringCentroid(rings[0]) : { x: 0, y: 0 };
    return (
      <g data-layer-id={id} data-state="visible" ref={ref} {...rest}>
        {rings.map((ring, index) => (
          <path
            d={`${ringToPath(ring)} Z`}
            fill={palette.fill}
            key={`${id ?? "layer"}-ring-${index.toString()}`}
            stroke={palette.stroke}
            strokeWidth={1.5}
          />
        ))}
        {label ? (
          <text
            className="select-none fill-foreground text-[11px] font-semibold"
            dominantBaseline="middle"
            textAnchor="middle"
            x={centroid.x}
            y={centroid.y}
          >
            {label}
          </text>
        ) : null}
      </g>
    );
  },
);
MapTimelineLayer.displayName = "MapTimelineLayer";

/**
 * Props for {@link MapTimelineEvent}.
 *
 * @public
 */
export type MapTimelineEventProps = {
  /** Color theme. Defaults to `"red"`. */
  color?: MapTimelineColor;
  /** Optional description rendered in the tooltip. */
  description?: ReactNode;
  /** Stable identifier. */
  id?: string;
  /** Geographic position. */
  position: GeoPosition;
  /** Title rendered in the tooltip. */
  title?: ReactNode;
  /** Inclusive ± window in years around `year` when the marker shows. Defaults to `0` (exact match). */
  toleranceYears?: number;
  /** Year the event happened. */
  year: number;
} & Omit<ComponentPropsWithoutRef<"g">, "id">;

/**
 * Year-pinned event marker.
 *
 * @public
 */
export const MapTimelineEvent = forwardRef<SVGGElement, MapTimelineEventProps>(
  (props, ref) => {
    const {
      color = "red",
      description,
      id,
      position,
      title,
      toleranceYears = 0,
      year: eventYear,
      ...rest
    } = props;
    const { year } = useTimelineContext();
    const visible = Math.abs(year - eventYear) <= toleranceYears;
    if (!visible) return null;
    const palette = PALETTE[color];
    const projected = projectEquirectangular(position);
    return (
      <g
        data-event-id={id}
        data-event-year={eventYear}
        ref={ref}
        transform={`translate(${projected.x.toString()}, ${projected.y.toString()})`}
        {...rest}
      >
        <circle
          className={cn("stroke-background", palette.dot)}
          r="6"
          strokeWidth="2"
        >
          {title ? (
            <title>{typeof title === "string" ? title : ""}</title>
          ) : null}
        </circle>
        {title ? (
          <text
            className="select-none fill-foreground text-[10px] font-semibold"
            dominantBaseline="middle"
            textAnchor="middle"
            y="-12"
          >
            {title}
          </text>
        ) : null}
        {description ? (
          <text
            className="select-none fill-muted-foreground text-[9px]"
            dominantBaseline="middle"
            textAnchor="middle"
            y="20"
          >
            {description}
          </text>
        ) : null}
      </g>
    );
  },
);
MapTimelineEvent.displayName = "MapTimelineEvent";

/**
 * Container for the slider + play button row.
 *
 * @public
 */
export const MapTimelineControls = forwardRef<
  HTMLDivElement,
  ComponentPropsWithoutRef<"div">
>(({ children, className, ...rest }, ref) => (
  <div
    className={cn(
      "flex items-center gap-3 border-t border-border bg-muted/40 px-4 py-2",
      className,
    )}
    ref={ref}
    {...rest}
  >
    {children}
  </div>
));
MapTimelineControls.displayName = "MapTimelineControls";

/**
 * Range-input slider bound to the current year.
 *
 * @public
 */
export const MapTimelineSlider = forwardRef<
  HTMLInputElement,
  Omit<
    ComponentPropsWithoutRef<"input">,
    "max" | "min" | "onChange" | "type" | "value"
  >
>(({ className, ...rest }, ref) => {
  const { endYear, labels, setYear, startYear, year } = useTimelineContext();
  const sliderId = useId();
  const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {
    setYear(Number.parseInt(event.target.value, 10));
  };
  return (
    <div className="flex flex-1 items-center gap-2 text-xs font-medium text-muted-foreground">
      <span aria-hidden="true">{formatYear(startYear)}</span>
      <input
        aria-label={labels.slider}
        aria-valuemax={endYear}
        aria-valuemin={startYear}
        aria-valuenow={year}
        className={cn("flex-1 accent-primary", className)}
        id={sliderId}
        max={endYear}
        min={startYear}
        onChange={handleChange}
        ref={ref}
        type="range"
        value={year}
        {...rest}
      />
      <span aria-hidden="true">{formatYear(endYear)}</span>
      <span
        className="ml-2 inline-flex min-w-20 justify-center rounded-md border border-border bg-background px-2 py-0.5 text-foreground"
        data-current-year={year}
      >
        {formatYear(year)}
      </span>
    </div>
  );
});
MapTimelineSlider.displayName = "MapTimelineSlider";

/**
 * Play / pause toggle that auto-advances the year on `requestAnimationFrame`.
 *
 * @public
 */
export const MapTimelinePlayButton = forwardRef<
  HTMLButtonElement,
  Omit<ComponentPropsWithoutRef<"button">, "aria-pressed" | "onClick" | "type">
>(({ className, ...rest }, ref) => {
  const { isPlaying, labels, setIsPlaying } = useTimelineContext();
  const handleClick = (): void => {
    setIsPlaying(!isPlaying);
  };
  return (
    <button
      aria-label={isPlaying ? labels.pause : labels.play}
      aria-pressed={isPlaying}
      className={cn(
        "inline-flex h-8 items-center rounded-md border border-border bg-background px-3 text-xs font-medium hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
        className,
      )}
      data-playing={isPlaying ? "true" : undefined}
      onClick={handleClick}
      ref={ref}
      type="button"
      {...rest}
    >
      <span aria-hidden="true">{isPlaying ? "❚❚" : "▶"}</span>
    </button>
  );
});
MapTimelinePlayButton.displayName = "MapTimelinePlayButton";

/**
 * Props for {@link MapTimeline}.
 *
 * @public
 */
export type MapTimelineProps = {
  /** Optional URL of a backdrop image (world map, terrain). */
  backdrop?: string;
  /** Aria-label for the backdrop image. */
  backdropAlt?: string;
  /** End of the timeline window (inclusive). */
  endYear: number;
  /** Initial year. Defaults to `startYear`. */
  initialYear?: number;
  /** Localizable strings. */
  labels?: MapTimelineLabels;
  /** Fires when the year changes (slider drag, play tick, or programmatic). */
  onYearChange?: (next: number) => void;
  /** Years per second when playing. Defaults to `25`. */
  speed?: number;
  /** Start of the timeline window (inclusive). Negative for BCE. */
  startYear: number;
} & Omit<ComponentPropsWithoutRef<"section">, "onChange">;

function useTimelineCtx(arguments_: {
  endYear: number;
  isPlaying: boolean;
  labels: Required<MapTimelineLabels>;
  setIsPlaying: (next: boolean) => void;
  setYear: (next: number) => void;
  speed: number;
  startYear: number;
  year: number;
}): Ctx {
  const {
    endYear,
    isPlaying,
    labels,
    setIsPlaying,
    setYear,
    speed,
    startYear,
    year,
  } = arguments_;
  return useMemo<Ctx>(
    () => ({
      endYear,
      isPlaying,
      labels,
      setIsPlaying,
      setYear,
      speed,
      startYear,
      year,
    }),
    [endYear, isPlaying, labels, setIsPlaying, setYear, speed, startYear, year],
  );
}

function useTimelineState(arguments_: {
  endYear: number;
  initialYear?: number;
  onYearChange?: (next: number) => void;
  speed: number;
  startYear: number;
}): {
  isPlaying: boolean;
  setIsPlaying: (next: boolean) => void;
  setYear: (next: number) => void;
  year: number;
} {
  const { endYear, initialYear, onYearChange, startYear } = arguments_;
  const [year, setYear] = useState<number>(
    clamp(initialYear ?? startYear, startYear, endYear),
  );
  const [isPlaying, setIsPlaying] = useState(false);

  const updateYear = useCallback(
    (next: number) => {
      const clamped = clamp(next, startYear, endYear);
      setYear((current) => {
        if (clamped >= endYear) setIsPlaying(false);
        if (current === clamped) return current;
        onYearChange?.(clamped);
        return clamped;
      });
    },
    [endYear, onYearChange, startYear],
  );

  return { isPlaying, setIsPlaying, setYear: updateYear, year };
}

function usePlayback(arguments_: {
  endYear: number;
  isPlaying: boolean;
  setIsPlaying: (next: boolean) => void;
  setYear: (next: number) => void;
  speed: number;
  startYear: number;
  year: number;
}): void {
  const { endYear, isPlaying, setIsPlaying, setYear, speed, year } = arguments_;
  const yearRef = useRef(year);
  useEffect(() => {
    yearRef.current = year;
  }, [year]);
  useEffect(() => {
    if (!isPlaying) return;
    if (typeof window === "undefined") return;
    let frame = 0;
    let last: null | number = null;
    const step = (timestamp: number): void => {
      if (last !== null) {
        const delta = (timestamp - last) / 1000;
        const next = yearRef.current + delta * speed;
        if (next >= endYear) {
          setYear(endYear);
          setIsPlaying(false);
          return;
        }
        setYear(next);
      }
      last = timestamp;
      frame = window.requestAnimationFrame(step);
    };
    frame = window.requestAnimationFrame(step);
    return () => {
      window.cancelAnimationFrame(frame);
    };
  }, [endYear, isPlaying, setIsPlaying, setYear, speed]);
}

type StageProps = {
  backdrop?: string;
  backdropAlt?: string;
  children: ReactNode;
};

function Stage({ backdrop, backdropAlt, children }: StageProps): ReactNode {
  return (
    <svg
      aria-hidden="true"
      className="block h-full w-full"
      preserveAspectRatio="xMidYMid meet"
      viewBox={`0 0 ${VIEWBOX_WIDTH.toString()} ${VIEWBOX_HEIGHT.toString()}`}
    >
      <rect
        className="fill-muted"
        height={VIEWBOX_HEIGHT}
        width={VIEWBOX_WIDTH}
        x="0"
        y="0"
      />
      {backdrop ? (
        <image
          aria-label={backdropAlt}
          height={VIEWBOX_HEIGHT}
          href={backdrop}
          preserveAspectRatio="xMidYMid slice"
          width={VIEWBOX_WIDTH}
          x="0"
          y="0"
        />
      ) : null}
      {children}
    </svg>
  );
}

type ChildBuckets = {
  footer: ReactNode[];
  stage: ReactNode[];
};

function bucketChildren(children: ReactNode): ChildBuckets {
  const list: ReactNode[] = Array.isArray(children) ? children : [children];
  return list.reduce<ChildBuckets>(
    (accumulator, child) => {
      const name = displayName(child);
      if (name === MapTimelineControls.displayName)
        accumulator.footer.push(child);
      else accumulator.stage.push(child);
      return accumulator;
    },
    { footer: [], stage: [] },
  );
}

function displayName(child: ReactNode): string | undefined {
  if (typeof child !== "object" || child === null) return undefined;
  if (!("type" in child)) return undefined;
  const type = (child as { type: unknown }).type;
  if (typeof type !== "object" && typeof type !== "function") return undefined;
  const name = (type as { displayName?: unknown }).displayName;
  return typeof name === "string" ? name : undefined;
}

type ShellProps = {
  backdrop?: string;
  backdropAlt?: string;
  buckets: ChildBuckets;
  className?: string;
  region: string;
  titleId: string;
  year: number;
};

const Shell = forwardRef<HTMLElement, ShellProps>(function Shell(props, ref) {
  const { backdrop, backdropAlt, buckets, className, region, titleId, year } =
    props;
  return (
    <section
      aria-labelledby={titleId}
      className={cn(
        "flex w-full flex-col overflow-hidden rounded-2xl border bg-background text-foreground",
        className,
      )}
      ref={ref}
    >
      <span className="sr-only" id={titleId}>
        {region}
      </span>
      <div
        className="relative aspect-[2/1] w-full overflow-hidden"
        data-current-year={year}
      >
        <Stage backdrop={backdrop} backdropAlt={backdropAlt}>
          {buckets.stage}
        </Stage>
      </div>
      {buckets.footer}
    </section>
  );
});

/**
 * Combined map + timeline. Pass {@link MapTimelineLayer} (era polygons),
 * {@link MapTimelineEvent} (year-pinned markers), and
 * {@link MapTimelineControls} as children. The slider scrubs the
 * current year; layers and events appear / disappear as the year
 * crosses their windows.
 *
 * Standalone SVG primitive — no external map library required.
 *
 * @example
 * ```tsx
 * <MapTimeline startYear={-500} endYear={2025} initialYear={1}>
 *   <MapTimelineLayer
 *     startYear={-27}
 *     endYear={476}
 *     color="red"
 *     label="Roman Empire"
 *     geometry={{ type: 'polygon', polygon: romanRing }}
 *   />
 *   <MapTimelineEvent
 *     year={79}
 *     position={[14.48, 40.75]}
 *     title="Vesuvius"
 *     description="Pompeii destroyed"
 *   />
 *   <MapTimelineControls>
 *     <MapTimelinePlayButton />
 *     <MapTimelineSlider />
 *   </MapTimelineControls>
 * </MapTimeline>
 * ```
 *
 * @public
 */
export const MapTimeline = forwardRef<HTMLElement, MapTimelineProps>(
  (props, ref) => {
    const {
      backdrop,
      backdropAlt,
      children,
      className,
      endYear,
      initialYear,
      labels,
      onYearChange,
      speed = 25,
      startYear,
      ...rest
    } = props;
    const titleId = useId();
    const resolvedLabels = useMemo(
      () => ({ ...DEFAULT_LABELS, ...labels }),
      [labels],
    );

    const { isPlaying, setIsPlaying, setYear, year } = useTimelineState({
      endYear,
      initialYear,
      onYearChange,
      speed,
      startYear,
    });

    usePlayback({
      endYear,
      isPlaying,
      setIsPlaying,
      setYear,
      speed,
      startYear,
      year,
    });

    const ctx = useTimelineCtx({
      endYear,
      isPlaying,
      labels: resolvedLabels,
      setIsPlaying,
      setYear,
      speed,
      startYear,
      year,
    });

    const buckets = bucketChildren(children);
    return (
      <TimelineContext.Provider value={ctx}>
        <Shell
          backdrop={backdrop}
          backdropAlt={backdropAlt}
          buckets={buckets}
          className={className}
          ref={ref}
          region={resolvedLabels.region}
          titleId={titleId}
          year={year}
          {...rest}
        />
      </TimelineContext.Provider>
    );
  },
);
MapTimeline.displayName = "MapTimeline";
typescript

Dependencies

  • @vllnt/ui@^0.2.1