Interactive Timeline

Zoomable, pannable, multi-track timeline with category filter, today marker, and click-to-select events.

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

Storybook

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

View in Storybook

Code

"use client";

import {
  type ComponentPropsWithoutRef,
  createContext,
  forwardRef,
  type MouseEvent as ReactMouseEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
  useCallback,
  useContext,
  useId,
  useMemo,
  useRef,
  useState,
  useSyncExternalStore,
} from "react";

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

const MIN_ZOOM = 1;
const MAX_ZOOM = 32;
const ZOOM_STEP = 2;
const MS_PER_DAY = 86_400_000;
const TICK_TARGET_PX = 120;

/**
 * Color theme for tracks and event categories.
 *
 * @public
 */
export type InteractiveTimelineColor =
  | "amber"
  | "blue"
  | "emerald"
  | "neutral"
  | "purple"
  | "red"
  | "rose";

const COLOR_PALETTE: Record<
  InteractiveTimelineColor,
  { bar: string; chip: string; chipActive: string; dot: string }
> = {
  amber: {
    bar: "bg-amber-500/80",
    chip: "border-amber-400 text-amber-700 dark:text-amber-300",
    chipActive: "bg-amber-500 text-white border-amber-500",
    dot: "border-amber-500 bg-amber-500",
  },
  blue: {
    bar: "bg-blue-500/80",
    chip: "border-blue-400 text-blue-700 dark:text-blue-300",
    chipActive: "bg-blue-500 text-white border-blue-500",
    dot: "border-blue-500 bg-blue-500",
  },
  emerald: {
    bar: "bg-emerald-500/80",
    chip: "border-emerald-400 text-emerald-700 dark:text-emerald-300",
    chipActive: "bg-emerald-500 text-white border-emerald-500",
    dot: "border-emerald-500 bg-emerald-500",
  },
  neutral: {
    bar: "bg-muted-foreground/70",
    chip: "border-border text-muted-foreground",
    chipActive: "bg-foreground text-background border-foreground",
    dot: "border-muted-foreground bg-muted-foreground",
  },
  purple: {
    bar: "bg-purple-500/80",
    chip: "border-purple-400 text-purple-700 dark:text-purple-300",
    chipActive: "bg-purple-500 text-white border-purple-500",
    dot: "border-purple-500 bg-purple-500",
  },
  red: {
    bar: "bg-red-500/80",
    chip: "border-red-400 text-red-700 dark:text-red-300",
    chipActive: "bg-red-500 text-white border-red-500",
    dot: "border-red-500 bg-red-500",
  },
  rose: {
    bar: "bg-rose-500/80",
    chip: "border-rose-400 text-rose-700 dark:text-rose-300",
    chipActive: "bg-rose-500 text-white border-rose-500",
    dot: "border-rose-500 bg-rose-500",
  },
};

/**
 * Track / lane definition.
 *
 * @public
 */
export type InteractiveTimelineTrack = {
  /** Color theme. Defaults to `"neutral"`. */
  color?: InteractiveTimelineColor;
  /** Stable id; matches {@link InteractiveTimelineEvent.track}. */
  id: string;
  /** Display label. */
  label: ReactNode;
};

/**
 * Category definition (drives the legend filter).
 *
 * @public
 */
export type InteractiveTimelineCategory = {
  /** Color theme override; falls back to the track color. */
  color?: InteractiveTimelineColor;
  /** Stable id; matches {@link InteractiveTimelineEvent.category}. */
  id: string;
  /** Display label. */
  label: ReactNode;
};

/**
 * A point or duration event.
 *
 * @public
 */
export type InteractiveTimelineEvent = {
  /** Optional category id used by the filter chips. */
  category?: string;
  /** Optional explicit color theme for this event. Overrides track + category. */
  color?: InteractiveTimelineColor;
  /** Optional description shown in the tooltip. */
  description?: ReactNode;
  /** End date for duration events. Omit for point events. */
  endDate?: Date;
  /** Stable id. */
  id: string;
  /** Start date. */
  startDate: Date;
  /** Display title. */
  title: ReactNode;
  /** Optional id of the track to render in. Defaults to the first track. */
  track?: string;
};

/**
 * Localizable strings.
 *
 * @public
 */
export type InteractiveTimelineLabels = {
  /** Aria-label for the timeline section. Defaults to `"Interactive timeline"`. */
  region?: string;
  /** Aria-label for the today button. Defaults to `"Jump to today"`. */
  today?: string;
  /** Aria-label for the zoom-in button. Defaults to `"Zoom in"`. */
  zoomIn?: string;
  /** Aria-label for the zoom-out button. Defaults to `"Zoom out"`. */
  zoomOut?: string;
};

const DEFAULT_LABELS = {
  region: "Interactive timeline",
  today: "Jump to today",
  zoomIn: "Zoom in",
  zoomOut: "Zoom out",
} as const satisfies Required<InteractiveTimelineLabels>;

/**
 * Props for {@link InteractiveTimeline}.
 *
 * @public
 */
export type InteractiveTimelineProps = {
  /** Optional category filter list. Renders a chip row that toggles per category. */
  categories?: InteractiveTimelineCategory[];
  /** End of the visible window. */
  endDate: Date;
  /** Events to render. */
  events?: InteractiveTimelineEvent[];
  /** Localizable strings. */
  labels?: InteractiveTimelineLabels;
  /** Fires after a marker click. */
  onEventClick?: (event: InteractiveTimelineEvent) => void;
  /** Start of the visible window. */
  startDate: Date;
  /** Track / lane definitions. Falls back to a single anonymous lane. */
  tracks?: InteractiveTimelineTrack[];
} & ComponentPropsWithoutRef<"section">;

type TimelineCtx = {
  centerToday: () => void;
  labels: Required<InteractiveTimelineLabels>;
  toggleCategory: (id: string) => void;
  visibleCategories: ReadonlySet<string>;
  zoom: number;
  zoomIn: () => void;
  zoomOut: () => void;
};

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

function useTimelineContext(): TimelineCtx {
  const ctx = useContext(TimelineContext);
  if (!ctx) {
    throw new Error("InteractiveTimeline 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 dateToOffset(date: Date, start: number, span: number): number {
  if (span <= 0) return 0;
  return (date.getTime() - start) / span;
}

function formatDate(date: Date): string {
  return date.toLocaleDateString(undefined, {
    day: "2-digit",
    month: "short",
    year: "numeric",
  });
}

function dayCount(start: number, end: number): number {
  return Math.max(1, Math.round((end - start) / MS_PER_DAY));
}

function buildTicks(
  start: number,
  end: number,
  containerWidth: number,
): { date: Date; offset: number }[] {
  if (containerWidth <= 0) return [];
  const targetCount = Math.max(2, Math.floor(containerWidth / TICK_TARGET_PX));
  const span = end - start;
  if (span <= 0) return [];
  const totalDays = dayCount(start, end);
  const stepDays = Math.max(1, Math.round(totalDays / (targetCount - 1)));
  const stepMs = stepDays * MS_PER_DAY;
  return Array.from({ length: targetCount }).map((_, index) => {
    const time = start + stepMs * index;
    const clamped = Math.min(time, end);
    return {
      date: new Date(clamped),
      offset: (clamped - start) / span,
    };
  });
}

function resolveEventColor(
  event: InteractiveTimelineEvent,
  tracks: InteractiveTimelineTrack[],
  categories: InteractiveTimelineCategory[],
): InteractiveTimelineColor {
  if (event.color) return event.color;
  if (event.category) {
    const cat = categories.find((c) => c.id === event.category);
    if (cat?.color) return cat.color;
  }
  if (event.track) {
    const track = tracks.find((t) => t.id === event.track);
    if (track?.color) return track.color;
  }
  return "neutral";
}

type AxisProps = {
  endTime: number;
  startTime: number;
  ticks: { date: Date; offset: number }[];
};

function Axis({ endTime, startTime, 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"
      data-end={endTime}
      data-start={startTime}
    >
      {ticks.map((tick) => (
        <span
          className="absolute top-1 -translate-x-1/2 whitespace-nowrap"
          key={tick.date.getTime()}
          style={{ left: `${(tick.offset * 100).toString()}%` }}
        >
          {formatDate(tick.date)}
        </span>
      ))}
    </div>
  );
}

type TodayMarkerProps = {
  endTime: number;
  startTime: number;
};

function getNow(): number {
  return Date.now();
}

function noopUnsubscribe(): void {
  return;
}

function emptySubscribe(): () => void {
  return noopUnsubscribe;
}

function useNow(): number {
  return useSyncExternalStore(emptySubscribe, getNow, getNow);
}

function TodayMarker({ endTime, startTime }: TodayMarkerProps): ReactNode {
  const now = useNow();
  if (now < startTime || now > endTime) return null;
  const offset = (now - startTime) / (endTime - startTime);
  return (
    <div
      aria-hidden="true"
      className="pointer-events-none absolute inset-y-0 z-20 w-px bg-primary"
      data-testid="today-marker"
      style={{ left: `${(offset * 100).toString()}%` }}
    >
      <span className="absolute -top-2 -translate-x-1/2 rounded bg-primary px-1 text-[10px] font-medium text-primary-foreground">
        Today
      </span>
    </div>
  );
}

type EventNodeProps = {
  active: boolean;
  categories: InteractiveTimelineCategory[];
  endTime: number;
  event: InteractiveTimelineEvent;
  onSelect: (event: InteractiveTimelineEvent) => void;
  startTime: number;
  tracks: InteractiveTimelineTrack[];
};

type EventGeometry = {
  isDuration: boolean;
  left: number;
  visible: boolean;
  width: number;
};

function eventGeometry(
  event: InteractiveTimelineEvent,
  startTime: number,
  endTime: number,
): EventGeometry {
  const span = endTime - startTime;
  const startOffset = dateToOffset(event.startDate, startTime, span);
  const endOffset = event.endDate
    ? dateToOffset(event.endDate, startTime, span)
    : startOffset;
  const visible =
    endOffset >= 0 && startOffset <= 1 && event.startDate.getTime() <= endTime;
  const left = clamp(startOffset, 0, 1);
  const width = Math.max(0, Math.min(1, endOffset) - left);
  return {
    isDuration: width > 0 && Boolean(event.endDate),
    left,
    visible,
    width,
  };
}

type EventTooltipProps = {
  event: InteractiveTimelineEvent;
  tooltipId: string;
};

function EventTooltip({ event, tooltipId }: EventTooltipProps): ReactNode {
  return (
    <span
      className="pointer-events-none absolute left-1/2 top-5 hidden -translate-x-1/2 whitespace-nowrap rounded border bg-popover px-2 py-1 text-[11px] font-medium text-popover-foreground shadow-md group-hover:block group-focus-visible:block"
      id={tooltipId}
      role="tooltip"
    >
      <span className="block">{event.title}</span>
      <span className="block text-[10px] text-muted-foreground">
        {formatDate(event.startDate)}
        {event.endDate ? `${formatDate(event.endDate)}` : null}
      </span>
      {event.description ? (
        <span className="block text-[10px] text-muted-foreground">
          {event.description}
        </span>
      ) : null}
    </span>
  );
}

function EventNode({
  active,
  categories,
  endTime,
  event,
  onSelect,
  startTime,
  tracks,
}: EventNodeProps): ReactNode {
  const color = resolveEventColor(event, tracks, categories);
  const palette = COLOR_PALETTE[color];
  const { isDuration, left, visible, width } = eventGeometry(
    event,
    startTime,
    endTime,
  );
  if (!visible) return null;
  const titleText = typeof event.title === "string" ? event.title : "Event";
  const tooltipId = `${event.id}-tooltip`;

  const handleClick = (
    mouseEvent: ReactMouseEvent<HTMLButtonElement>,
  ): void => {
    mouseEvent.stopPropagation();
    onSelect(event);
  };

  return (
    <button
      aria-describedby={event.description ? tooltipId : undefined}
      aria-label={titleText}
      aria-pressed={active}
      className={cn(
        "group absolute top-1/2 z-10 -translate-y-1/2 cursor-pointer rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
        isDuration ? "" : "-translate-x-1/2",
      )}
      data-event-id={event.id}
      data-event-track={event.track ?? ""}
      data-selected={active ? "true" : undefined}
      onClick={handleClick}
      style={{
        left: `${(left * 100).toString()}%`,
        width: isDuration ? `${(width * 100).toString()}%` : undefined,
      }}
      type="button"
    >
      {isDuration ? (
        <span
          className={cn(
            "block h-3 rounded-sm shadow-sm ring-2 ring-background",
            palette.bar,
            active ? "ring-primary" : "",
          )}
        />
      ) : (
        <span
          className={cn(
            "block size-3 rounded-full border-2 ring-2 ring-background",
            palette.dot,
            active ? "ring-primary" : "",
          )}
        />
      )}
      <EventTooltip event={event} tooltipId={tooltipId} />
    </button>
  );
}

type TrackRowProps = {
  categories: InteractiveTimelineCategory[];
  endTime: number;
  events: InteractiveTimelineEvent[];
  onSelect: (event: InteractiveTimelineEvent) => void;
  selectedId?: string;
  startTime: number;
  track: InteractiveTimelineTrack;
};

function TrackRow({
  categories,
  endTime,
  events,
  onSelect,
  selectedId,
  startTime,
  track,
}: TrackRowProps): ReactNode {
  const palette = COLOR_PALETTE[track.color ?? "neutral"];
  return (
    <div
      className="relative flex h-12 items-center border-t border-border/60"
      data-track-id={track.id}
    >
      <div className="absolute left-0 z-30 flex h-full w-32 shrink-0 items-center gap-2 border-r border-border bg-background px-3 text-xs font-medium">
        <span
          aria-hidden="true"
          className={cn("size-2 rounded-full", palette.dot)}
        />
        <span className="truncate">{track.label}</span>
      </div>
      <div className="relative ml-32 h-full flex-1">
        <div className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-border" />
        {events.map((event) => (
          <EventNode
            active={selectedId === event.id}
            categories={categories}
            endTime={endTime}
            event={event}
            key={event.id}
            onSelect={onSelect}
            startTime={startTime}
            tracks={[track]}
          />
        ))}
      </div>
    </div>
  );
}

type ScrollAreaProps = {
  categories: InteractiveTimelineCategory[];
  endTime: number;
  events: InteractiveTimelineEvent[];
  onSelect: (event: InteractiveTimelineEvent) => void;
  selectedId?: string;
  startTime: number;
  tracks: InteractiveTimelineTrack[];
  zoom: number;
};

type ScrollDragHandlers = {
  onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;
  onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
  onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
  onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
};

function useScrollDrag(
  ref: React.RefObject<HTMLDivElement | null>,
): ScrollDragHandlers {
  const dragRef = useRef<null | { originScroll: number; originX: number }>(
    null,
  );

  const onPointerDown = useCallback(
    (event: ReactPointerEvent<HTMLDivElement>): void => {
      const node = ref.current;
      if (!node) return;
      dragRef.current = {
        originScroll: node.scrollLeft,
        originX: event.clientX,
      };
      node.setPointerCapture(event.pointerId);
    },
    [ref],
  );

  const onPointerMove = useCallback(
    (event: ReactPointerEvent<HTMLDivElement>): void => {
      const node = ref.current;
      const drag = dragRef.current;
      if (!node || !drag) return;
      node.scrollLeft = drag.originScroll - (event.clientX - drag.originX);
    },
    [ref],
  );

  const onPointerEnd = useCallback(
    (event: ReactPointerEvent<HTMLDivElement>): void => {
      const node = ref.current;
      if (!node) return;
      if (node.hasPointerCapture(event.pointerId)) {
        node.releasePointerCapture(event.pointerId);
      }
      dragRef.current = null;
    },
    [ref],
  );

  return {
    onPointerCancel: onPointerEnd,
    onPointerDown,
    onPointerMove,
    onPointerUp: onPointerEnd,
  };
}

function ScrollArea({
  categories,
  endTime,
  events,
  onSelect,
  selectedId,
  startTime,
  tracks,
  zoom,
}: ScrollAreaProps): ReactNode {
  const ref = useRef<HTMLDivElement | null>(null);
  const [containerWidth, setContainerWidth] = useState(800);
  const dragHandlers = useScrollDrag(ref);

  const handleRef = useCallback((node: HTMLDivElement | null) => {
    ref.current = node;
    if (node) setContainerWidth(node.clientWidth);
  }, []);

  const innerWidth = `${(zoom * 100).toString()}%`;
  const ticks = useMemo(
    () => buildTicks(startTime, endTime, containerWidth * zoom),
    [containerWidth, endTime, startTime, zoom],
  );

  return (
    <div
      className="relative w-full cursor-grab overflow-x-auto active:cursor-grabbing"
      data-zoom={zoom}
      ref={handleRef}
      {...dragHandlers}
    >
      <div className="relative" style={{ width: innerWidth }}>
        <Axis endTime={endTime} startTime={startTime} ticks={ticks} />
        <div className="relative">
          <TodayMarker endTime={endTime} startTime={startTime} />
          {tracks.map((track) => {
            const trackEvents = events.filter(
              (event) => (event.track ?? tracks[0]?.id) === track.id,
            );
            return (
              <TrackRow
                categories={categories}
                endTime={endTime}
                events={trackEvents}
                key={track.id}
                onSelect={onSelect}
                selectedId={selectedId}
                startTime={startTime}
                track={track}
              />
            );
          })}
        </div>
      </div>
    </div>
  );
}

/**
 * Toolbar slot. Pass {@link InteractiveTimelineZoomIn},
 * {@link InteractiveTimelineZoomOut}, {@link InteractiveTimelineToday},
 * {@link InteractiveTimelineFilter} as children.
 *
 * @public
 */
export const InteractiveTimelineToolbar = forwardRef<
  HTMLDivElement,
  ComponentPropsWithoutRef<"div">
>(({ children, className, ...rest }, ref) => (
  <div
    className={cn(
      "flex flex-wrap items-center gap-2 border-b border-border bg-muted/30 px-3 py-2",
      className,
    )}
    ref={ref}
    role="toolbar"
    {...rest}
  >
    {children}
  </div>
));
InteractiveTimelineToolbar.displayName = "InteractiveTimelineToolbar";

/**
 * Zoom-in button. Doubles the zoom factor up to a max.
 *
 * @public
 */
export const InteractiveTimelineZoomIn = forwardRef<
  HTMLButtonElement,
  Omit<ComponentPropsWithoutRef<"button">, "type">
>(({ className, ...rest }, ref) => {
  const { labels, zoomIn } = useTimelineContext();
  return (
    <button
      aria-label={labels.zoomIn}
      className={cn(
        "inline-flex size-8 items-center justify-center rounded-md border border-border bg-background text-sm font-medium hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
        className,
      )}
      onClick={zoomIn}
      ref={ref}
      type="button"
      {...rest}
    >
      <span aria-hidden="true">+</span>
    </button>
  );
});
InteractiveTimelineZoomIn.displayName = "InteractiveTimelineZoomIn";

/**
 * Zoom-out button.
 *
 * @public
 */
export const InteractiveTimelineZoomOut = forwardRef<
  HTMLButtonElement,
  Omit<ComponentPropsWithoutRef<"button">, "type">
>(({ className, ...rest }, ref) => {
  const { labels, zoomOut } = useTimelineContext();
  return (
    <button
      aria-label={labels.zoomOut}
      className={cn(
        "inline-flex size-8 items-center justify-center rounded-md border border-border bg-background text-sm font-medium hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
        className,
      )}
      onClick={zoomOut}
      ref={ref}
      type="button"
      {...rest}
    >
      <span aria-hidden="true"></span>
    </button>
  );
});
InteractiveTimelineZoomOut.displayName = "InteractiveTimelineZoomOut";

/**
 * Today button. Centers the view on `Date.now()` if it falls inside the
 * timeline window.
 *
 * @public
 */
export const InteractiveTimelineToday = forwardRef<
  HTMLButtonElement,
  Omit<ComponentPropsWithoutRef<"button">, "type">
>(({ children, className, ...rest }, ref) => {
  const { centerToday, labels } = useTimelineContext();
  return (
    <button
      aria-label={labels.today}
      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,
      )}
      onClick={centerToday}
      ref={ref}
      type="button"
      {...rest}
    >
      {children ?? "Today"}
    </button>
  );
});
InteractiveTimelineToday.displayName = "InteractiveTimelineToday";

/**
 * Category filter chips. Toggles visibility of events by category.
 *
 * @public
 */
export type InteractiveTimelineFilterProps = {
  categories: InteractiveTimelineCategory[];
} & Omit<ComponentPropsWithoutRef<"div">, "children">;

export const InteractiveTimelineFilter = forwardRef<
  HTMLDivElement,
  InteractiveTimelineFilterProps
>(({ categories, className, ...rest }, ref) => {
  const { toggleCategory, visibleCategories } = useTimelineContext();
  return (
    <div
      className={cn("flex flex-wrap items-center gap-1.5", className)}
      ref={ref}
      role="group"
      {...rest}
    >
      {categories.map((category) => {
        const active = visibleCategories.has(category.id);
        const palette = COLOR_PALETTE[category.color ?? "neutral"];
        return (
          <button
            aria-pressed={active}
            className={cn(
              "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
              active ? palette.chipActive : palette.chip,
            )}
            data-category-id={category.id}
            key={category.id}
            onClick={() => {
              toggleCategory(category.id);
            }}
            type="button"
          >
            {category.label}
          </button>
        );
      })}
    </div>
  );
});
InteractiveTimelineFilter.displayName = "InteractiveTimelineFilter";

const FALLBACK_TRACK: InteractiveTimelineTrack = {
  color: "neutral",
  id: "default",
  label: "Events",
};

function noop(): void {
  return;
}

function useToolbarHandlers(arguments_: {
  endTime: number;
  scrollerId: string;
  setZoom: (next: ((previous: number) => number) | number) => void;
  startTime: number;
  zoom: number;
}): {
  centerToday: () => void;
  zoomIn: () => void;
  zoomOut: () => void;
} {
  const { endTime, scrollerId, setZoom, startTime } = arguments_;
  const zoomIn = useCallback(() => {
    setZoom((current) => clamp(current * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));
  }, [setZoom]);
  const zoomOut = useCallback(() => {
    setZoom((current) => clamp(current / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));
  }, [setZoom]);
  const centerToday = useCallback(() => {
    if (typeof document === "undefined") return;
    const node = document.querySelector<HTMLElement>(
      `[data-scroller-id="${scrollerId}"]`,
    );
    if (!node) return;
    const span = endTime - startTime;
    if (span <= 0) return;
    const now = Date.now();
    const offset = clamp((now - startTime) / span, 0, 1);
    const targetX = offset * node.scrollWidth - node.clientWidth / 2;
    node.scrollLeft = clamp(targetX, 0, node.scrollWidth);
  }, [endTime, scrollerId, startTime]);
  return { centerToday, zoomIn, zoomOut };
}

type FilterState = {
  filteredEvents: InteractiveTimelineEvent[];
  toggleCategory: (id: string) => void;
  visibleCategories: ReadonlySet<string>;
};

type SelectionState = {
  handleSelect: (event: InteractiveTimelineEvent) => void;
  selectedId?: string;
};

type Frame = {
  endTime: number;
  resolvedLabels: Required<InteractiveTimelineLabels>;
  startTime: number;
  tracks: InteractiveTimelineTrack[];
};

function useTimelineFrame(arguments_: {
  endDate: Date;
  labels?: InteractiveTimelineLabels;
  startDate: Date;
  trackProperty?: InteractiveTimelineTrack[];
}): Frame {
  const { endDate, labels, startDate, trackProperty } = arguments_;
  const tracks =
    trackProperty && trackProperty.length > 0
      ? trackProperty
      : [FALLBACK_TRACK];
  const resolvedLabels = useMemo(
    () => ({ ...DEFAULT_LABELS, ...labels }),
    [labels],
  );
  return {
    endTime: endDate.getTime(),
    resolvedLabels,
    startTime: startDate.getTime(),
    tracks,
  };
}

function useEventSelection(
  onEventClick: (event: InteractiveTimelineEvent) => void,
): SelectionState {
  const [selectedId, setSelectedId] = useState<string | undefined>();
  const handleSelect = useCallback(
    (event: InteractiveTimelineEvent) => {
      setSelectedId(event.id);
      onEventClick(event);
    },
    [onEventClick],
  );
  return { handleSelect, selectedId };
}

function useTimelineContextValue(arguments_: {
  centerToday: () => void;
  labels: Required<InteractiveTimelineLabels>;
  toggleCategory: (id: string) => void;
  visibleCategories: ReadonlySet<string>;
  zoom: number;
  zoomIn: () => void;
  zoomOut: () => void;
}): TimelineCtx {
  const {
    centerToday,
    labels,
    toggleCategory,
    visibleCategories,
    zoom,
    zoomIn,
    zoomOut,
  } = arguments_;
  return useMemo<TimelineCtx>(
    () => ({
      centerToday,
      labels,
      toggleCategory,
      visibleCategories,
      zoom,
      zoomIn,
      zoomOut,
    }),
    [
      centerToday,
      labels,
      toggleCategory,
      visibleCategories,
      zoom,
      zoomIn,
      zoomOut,
    ],
  );
}

function useTimelineFilter(
  categories: InteractiveTimelineCategory[],
  events: InteractiveTimelineEvent[],
): FilterState {
  const [hidden, setHidden] = useState<ReadonlySet<string>>(() => new Set());

  const visibleCategories = useMemo(
    () =>
      new Set(
        categories
          .filter((category) => !hidden.has(category.id))
          .map((category) => category.id),
      ),
    [categories, hidden],
  );

  const toggleCategory = useCallback((id: string) => {
    setHidden((current) => {
      const next = new Set(current);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);

  const filteredEvents = useMemo(() => {
    if (categories.length === 0) return events;
    return events.filter((event) => {
      if (!event.category) return true;
      return !hidden.has(event.category);
    });
  }, [categories.length, events, hidden]);

  return { filteredEvents, toggleCategory, visibleCategories };
}

/**
 * Zoomable, pannable, multi-track timeline. Drag horizontally to pan;
 * use {@link InteractiveTimelineZoomIn} / {@link InteractiveTimelineZoomOut}
 * to change zoom; click events to select. Out of scope for the MVP:
 * minimap, virtual rendering, image export, pinch-to-zoom — pan + button
 * zoom cover the data-rich case.
 *
 * @example
 * ```tsx
 * <InteractiveTimeline
 *   startDate={new Date("2024-01-01")}
 *   endDate={new Date("2026-12-31")}
 *   tracks={[{ id: "release", label: "Releases", color: "blue" }]}
 *   events={[
 *     { id: "v1", title: "v1.0", startDate: new Date("2024-06-01"), track: "release" },
 *   ]}
 *   onEventClick={(event) => console.info(event.id)}
 * >
 *   <InteractiveTimelineToolbar>
 *     <InteractiveTimelineZoomIn />
 *     <InteractiveTimelineZoomOut />
 *     <InteractiveTimelineToday />
 *   </InteractiveTimelineToolbar>
 * </InteractiveTimeline>
 * ```
 *
 * @public
 */
export const InteractiveTimeline = forwardRef<
  HTMLElement,
  InteractiveTimelineProps
>((props, ref) => {
  const {
    categories = [],
    children,
    className,
    endDate,
    events = [],
    labels,
    onEventClick = noop,
    startDate,
    tracks: trackProperty,
    ...rest
  } = props;

  const { endTime, resolvedLabels, startTime, tracks } = useTimelineFrame({
    endDate,
    labels,
    startDate,
    trackProperty,
  });
  const scrollerId = useId();

  const [zoom, setZoom] = useState<number>(1);

  const { filteredEvents, toggleCategory, visibleCategories } =
    useTimelineFilter(categories, events);

  const { centerToday, zoomIn, zoomOut } = useToolbarHandlers({
    endTime,
    scrollerId,
    setZoom,
    startTime,
    zoom,
  });

  const { handleSelect, selectedId } = useEventSelection(onEventClick);

  const ctx = useTimelineContextValue({
    centerToday,
    labels: resolvedLabels,
    toggleCategory,
    visibleCategories,
    zoom,
    zoomIn,
    zoomOut,
  });

  return (
    <TimelineContext.Provider value={ctx}>
      <section
        aria-label={resolvedLabels.region}
        className={cn(
          "flex w-full flex-col overflow-hidden rounded-2xl border bg-background text-foreground",
          className,
        )}
        ref={ref}
        {...rest}
      >
        {children}
        <div data-scroller-id={scrollerId} id={scrollerId}>
          <ScrollArea
            categories={categories}
            endTime={endTime}
            events={filteredEvents}
            onSelect={handleSelect}
            selectedId={selectedId}
            startTime={startTime}
            tracks={tracks}
            zoom={zoom}
          />
        </div>
      </section>
    </TimelineContext.Provider>
  );
});
InteractiveTimeline.displayName = "InteractiveTimeline";
typescript

Dependencies

  • @vllnt/ui@^0.2.1