Chronological Timeline

Media-rich, scroll-driven chronological timeline with alternating cards, image/video/audio media, and a progress strip.

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/chronological-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 ReactNode, use, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { cn } from "../../lib/utils"; /** * Media payload for a {@link ChronoEvent}. * * @public */ export type ChronoMedia = | { alt: string; caption?: ReactNode; credit?: ReactNode; src: string; type: "image"; } | { alt?: string; caption?: ReactNode; credit?: ReactNode; src: string; type: "audio"; } | { caption?: ReactNode; credit?: ReactNode; src: string; title?: string; type: "video"; }; type ChronoCtx = { registerEvent: (id: string, node: HTMLElement | null) => void; setActiveId: (id: string) => void; titleId: string; }; const ChronoContext = createContext<ChronoCtx | null>(null); function useChronoContext(): ChronoCtx { const ctx = use(ChronoContext); if (!ctx) { throw new Error("ChronoEvent used outside ChronologicalTimeline."); } return ctx; } /** * Props for {@link ChronologicalTimeline}. * * @public */ export type ChronologicalTimelineProps = { /** Aria-label for the timeline progress strip. Defaults to `"Timeline progress"`. */ progressLabel?: string; /** Headline rendered at the top of the timeline. */ title?: ReactNode; } & ComponentPropsWithoutRef<"section">; /** * Props for {@link ChronoEvent}. * * @public */ export type ChronoEventProps = { /** Display date (free-form string — pass `"1957"`, `"October 4, 1957"`, etc.). */ date: ReactNode; /** When `true`, the card renders larger to emphasise pivotal events. */ featured?: boolean; /** Stable id. Defaults to a generated id; pass one for deep links. */ id?: string; /** Optional media payload — image / video / audio. */ media?: ChronoMedia; /** Optional subtitle. */ subtitle?: ReactNode; /** Card title. */ title: ReactNode; } & Omit<ComponentPropsWithoutRef<"article">, "id" | "title">; type ImageMediaProps = { media: Extract<ChronoMedia, { type: "image" }>; }; function ImageMedia({ media }: ImageMediaProps): ReactNode { return ( <figure className="overflow-hidden rounded-xl border bg-muted"> <img alt={media.alt} className="aspect-video w-full object-cover" loading="lazy" src={media.src} /> {media.caption || media.credit ? ( <figcaption className="border-t bg-background px-3 py-2 text-xs text-muted-foreground"> {media.caption ? ( <span className="block">{media.caption}</span> ) : null} {media.credit ? ( <span className="block italic">{media.credit}</span> ) : null} </figcaption> ) : null} </figure> ); } type VideoMediaProps = { media: Extract<ChronoMedia, { type: "video" }>; }; function VideoMedia({ media }: VideoMediaProps): ReactNode { const iframeTitle = media.title || "Embedded timeline video"; return ( <figure className="overflow-hidden rounded-xl border bg-muted"> <div className="aspect-video w-full"> <iframe allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowFullScreen className="h-full w-full" src={media.src} title={iframeTitle} /> </div> {media.caption || media.credit ? ( <figcaption className="border-t bg-background px-3 py-2 text-xs text-muted-foreground"> {media.caption ? ( <span className="block">{media.caption}</span> ) : null} {media.credit ? ( <span className="block italic">{media.credit}</span> ) : null} </figcaption> ) : null} </figure> ); } type AudioMediaProps = { media: Extract<ChronoMedia, { type: "audio" }>; }; function AudioMedia({ media }: AudioMediaProps): ReactNode { return ( <figure className="overflow-hidden rounded-xl border bg-muted p-3"> <audio aria-label={media.alt} className="w-full" controls preload="metadata" src={media.src} > <track kind="captions" /> </audio> {media.caption || media.credit ? ( <figcaption className="pt-2 text-xs text-muted-foreground"> {media.caption ? ( <span className="block">{media.caption}</span> ) : null} {media.credit ? ( <span className="block italic">{media.credit}</span> ) : null} </figcaption> ) : null} </figure> ); } type MediaProps = { media: ChronoMedia; }; function Media({ media }: MediaProps): ReactNode { if (media.type === "image") return <ImageMedia media={media} />; if (media.type === "video") return <VideoMedia media={media} />; return <AudioMedia media={media} />; } type DateColumnProps = { date: ReactNode; }; function DateColumn({ date }: DateColumnProps): ReactNode { return ( <div className="hidden items-start justify-end pr-4 text-right text-xs font-semibold uppercase tracking-wide text-muted-foreground md:flex md:group-[[data-side='right']]:order-3 md:group-[[data-side='right']]:justify-start md:group-[[data-side='right']]:pl-4 md:group-[[data-side='right']]:text-left"> <time className="pt-2">{date}</time> </div> ); } type RailColumnProps = { featured: boolean; }; function RailColumn({ featured }: RailColumnProps): ReactNode { return ( <div aria-hidden="true" className="relative hidden md:flex md:w-6 md:items-start md:justify-center" > <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border" /> <span className={cn( "relative z-10 mt-3 block size-3 rounded-full border-2 border-background bg-primary", featured ? "size-4" : "", )} /> </div> ); } type EventCardProps = { children?: ReactNode; date: ReactNode; eventId: string; featured: boolean; media?: ChronoMedia; subtitle?: ReactNode; title: ReactNode; }; function EventCard({ children, date, eventId, featured, media, subtitle, title, }: EventCardProps): ReactNode { return ( <div className={cn( "rounded-2xl border bg-background p-5 shadow-sm md:group-[[data-side='right']]:order-1", featured ? "ring-1 ring-primary/30" : "", )} > <header className="mb-3 flex flex-col gap-1"> <time className="text-xs font-semibold uppercase tracking-wide text-muted-foreground md:hidden"> {date} </time> <h3 className={cn( "font-semibold text-foreground", featured ? "text-xl" : "text-lg", )} id={`${eventId}-title`} > {title} </h3> {subtitle ? ( <p className="text-sm text-muted-foreground">{subtitle}</p> ) : null} </header> {media ? ( <div className="mb-3"> <Media media={media} /> </div> ) : null} {children ? ( <div className="space-y-2 text-sm leading-relaxed text-foreground [&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-muted-foreground"> {children} </div> ) : null} </div> ); } /** * A single event card inside {@link ChronologicalTimeline}. Place * narrative paragraphs, blockquotes, and lists as children. * * @public */ export const ChronoEvent = ({ ref: forwardedRef, ...props }: ChronoEventProps & { ref?: React.Ref<HTMLElement> }) => { const { children, className, date, featured = false, id, media, subtitle, title, ...rest } = props; const generatedId = useId(); const eventId = id ?? generatedId; const ref = useRef<HTMLElement | null>(null); const { registerEvent, setActiveId } = useChronoContext(); const refCallback = useCallback( (node: HTMLElement | null) => { ref.current = node; registerEvent(eventId, node); if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [eventId, forwardedRef, registerEvent], ); const handleFocusEvent = useCallback(() => { setActiveId(eventId); }, [eventId, setActiveId]); return ( /* Passive focus tracking: the container observes focus bubbling from its interactive children to drive the scroll-spy active state. */ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions <article aria-labelledby={`${eventId}-title`} className={cn( "group relative grid gap-4 py-6 md:grid-cols-[1fr_auto_1fr] md:gap-8", className, )} data-event-id={eventId} data-featured={featured ? "true" : undefined} id={eventId} onFocus={handleFocusEvent} ref={refCallback} {...rest} > <DateColumn date={date} /> <RailColumn featured={featured} /> <EventCard date={date} eventId={eventId} featured={featured} media={media} subtitle={subtitle} title={title} > {children} </EventCard> </article> ); }; ChronoEvent.displayName = "ChronoEvent"; type ProgressStripProps = { activeId?: string; ids: string[]; label: string; }; function ProgressStrip({ activeId, ids, label, }: ProgressStripProps): ReactNode { if (ids.length === 0) return null; const activeIndex = activeId ? ids.indexOf(activeId) : -1; const ratio = activeIndex < 0 ? 0 : (activeIndex + 1) / ids.length; return ( <div aria-label={label} aria-valuemax={100} aria-valuemin={0} aria-valuenow={Math.round(ratio * 100)} className="sticky top-0 z-10 h-1 w-full bg-border" role="progressbar" > <span className="block h-full bg-primary transition-[width] duration-200" style={{ width: `${(ratio * 100).toString()}%` }} /> </div> ); } function useChronoActiveTracker(): { activeId?: string; ids: string[]; registerEvent: (id: string, node: HTMLElement | null) => void; setActiveId: (id: string) => void; } { const eventsRef = useRef<Map<string, HTMLElement>>(new Map()); const [ids, setIds] = useState<string[]>([]); const [activeId, setActiveId] = useState<string | undefined>(); const registerEvent = useCallback((id: string, node: HTMLElement | null) => { const map = eventsRef.current; if (node) map.set(id, node); else map.delete(id); setIds([...map.keys()]); }, []); useEffect(() => { if (typeof IntersectionObserver === "undefined") return; const observer = new IntersectionObserver( (entries) => { const visible = entries .filter((entry) => entry.isIntersecting) .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); const first = visible[0]; if (first) { const target = first.target; if (target instanceof HTMLElement) { const eventId = target.dataset.eventId; if (eventId) setActiveId(eventId); } } }, { rootMargin: "-30% 0px -50% 0px", threshold: 0.1 }, ); [...eventsRef.current.values()].forEach((node) => { observer.observe(node); }); return () => { observer.disconnect(); }; }, [ids]); return { activeId, ids, registerEvent, setActiveId }; } type EventListProps = { activeId?: string; children: ReactNode; }; function EventList({ activeId, children }: EventListProps): ReactNode { if (!Array.isArray(children)) { return ( <ol className="relative flex flex-col px-4 pb-6 md:px-6">{children}</ol> ); } return ( <ol className="relative flex flex-col px-4 pb-6 md:px-6"> {children.map((child, index) => ( <li className="group block list-none" data-active={ isReactElementWithEventId(child, activeId) ? "true" : undefined } data-side={index % 2 === 0 ? "left" : "right"} key={getChildKey(child, index)} > {child} </li> ))} </ol> ); } /** * Media-rich, scroll-driven chronological timeline. Cards alternate * sides on desktop and stack on mobile. Each {@link ChronoEvent} can * include an image, video, or audio payload plus narrative children * (paragraphs, blockquotes, lists). An `IntersectionObserver` follows * the reader and drives the progress strip + the active card flag. * * @example * ```tsx * <ChronologicalTimeline title="The Space Race"> * <ChronoEvent date="October 4, 1957" title="Sputnik 1" subtitle="First artificial satellite"> * <p>The Soviet Union launched Sputnik 1...</p> * </ChronoEvent> * <ChronoEvent date="July 20, 1969" title="Apollo 11" featured> * <p>Neil Armstrong and Buzz Aldrin walked on the Moon...</p> * </ChronoEvent> * </ChronologicalTimeline> * ``` * * @public */ export const ChronologicalTimeline = ({ ref, ...props }: ChronologicalTimelineProps & { ref?: React.Ref<HTMLElement> }) => { const { children, className, progressLabel = "Timeline progress", title, ...rest } = props; const titleId = useId(); const { activeId, ids, registerEvent, setActiveId } = useChronoActiveTracker(); const ctx = useMemo<ChronoCtx>( () => ({ registerEvent, setActiveId, titleId }), [registerEvent, setActiveId, titleId], ); return ( <ChronoContext.Provider value={ctx}> <section aria-labelledby={title ? titleId : undefined} className={cn( "relative mx-auto flex w-full max-w-4xl flex-col overflow-hidden rounded-2xl border bg-background text-foreground", className, )} ref={ref} {...rest} > <ProgressStrip activeId={activeId} ids={ids} label={progressLabel} /> {title ? ( <header className="p-6"> <h2 className="text-2xl font-semibold tracking-tight" id={titleId}> {title} </h2> </header> ) : null} <EventList activeId={activeId}>{children}</EventList> </section> </ChronoContext.Provider> ); }; ChronologicalTimeline.displayName = "ChronologicalTimeline"; function isReactElementWithEventId( child: unknown, activeId: string | undefined, ): boolean { if (!activeId) return false; if (typeof child !== "object" || child === null) return false; if (!("props" in child)) return false; const props = (child as { props: unknown }).props; if (typeof props !== "object" || props === null) return false; const id = (props as { id?: unknown }).id; return typeof id === "string" && id === activeId; } function getChildKey(child: unknown, fallback: number): number | string { if (typeof child !== "object" || child === null) return fallback; if (!("key" in child)) return fallback; const key = (child as { key: unknown }).key; if (typeof key === "string" || typeof key === "number") return key; return fallback; }

Dependencies

  • @vllnt/ui@^0.3.0