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

Storybook

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

View in Storybook

Code

"use client"; import { type ComponentPropsWithoutRef, createContext, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, use, useCallback, 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"> & { ref?: React.Ref<HTMLElement>; }; 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 = use(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 handleSelectTimelineEvent = ( 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={handleSelectTimelineEvent} 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; scrollerId: string; 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, scrollerId, selectedId, startTime, tracks, zoom, }: ScrollAreaProps): ReactNode { const ref = useRef<HTMLDivElement | null>(null); const observerRef = useRef<null | ResizeObserver>(null); const [containerWidth, setContainerWidth] = useState(800); const dragHandlers = useScrollDrag(ref); const handleRef = useCallback((node: HTMLDivElement | null) => { ref.current = node; observerRef.current?.disconnect(); observerRef.current = null; if (!node) return; setContainerWidth(node.clientWidth); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { setContainerWidth(node.clientWidth); }); observer.observe(node); observerRef.current = observer; }, []); 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-scroller-id={scrollerId} 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 = ({ children, className, ref, ...rest }: ComponentPropsWithoutRef<"div"> & { ref?: React.Ref<HTMLDivElement> }) => ( <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 = ({ className, ref, ...rest }: Omit<ComponentPropsWithoutRef<"button">, "type"> & { ref?: React.Ref<HTMLButtonElement>; }) => { 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 = ({ className, ref, ...rest }: Omit<ComponentPropsWithoutRef<"button">, "type"> & { ref?: React.Ref<HTMLButtonElement>; }) => { 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 = ({ children, className, ref, ...rest }: Omit<ComponentPropsWithoutRef<"button">, "type"> & { ref?: React.Ref<HTMLButtonElement>; }) => { 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 = ({ categories, className, ref, ...rest }: InteractiveTimelineFilterProps & { ref?: React.Ref<HTMLDivElement> }) => { 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( () => categories.reduce<Set<string>>((visible, category) => { if (!hidden.has(category.id)) { visible.add(category.id); } return visible; }, new Set()), [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 = (props: InteractiveTimelineProps) => { const { categories = [], children, className, endDate, events = [], labels, onEventClick = noop, ref, 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 id={scrollerId}> <ScrollArea categories={categories} endTime={endTime} events={filteredEvents} onSelect={handleSelect} scrollerId={scrollerId} selectedId={selectedId} startTime={startTime} tracks={tracks} zoom={zoom} /> </div> </section> </TimelineContext.Provider> ); }; InteractiveTimeline.displayName = "InteractiveTimeline";

Dependencies

  • @vllnt/ui@^0.3.0