Chronological Timeline

Chronologie riche en medias et pilotee par le defilement avec des cartes alternees, des medias image/video/audio et une barre de progression. Installez Chronological Timeline depuis le registre VLLNT UI avec la CLI shadcn.

Signaler un bug

Chronologie riche en medias et pilotee par le defilement avec des cartes alternees, des medias image/video/audio et une barre de progression. Fait partie de la famille data-display dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Installation

Ajoutez Chronological Timeline a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/chronological-timeline.json

Source

"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; }

Stories

Explorez chaque variante et etat dans le Storybook interactif :

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Dependances

  • @vllnt/ui@^0.3.0