A vertical activity timeline whose rail draws in on scroll and entries blur-rise with a stagger, exactly once, with tabular-nums timestamps.
npx shadcn@latest add @paragon/timeline"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
interface TimelineContextValue {
/** Entries have been revealed (or never animate). */
shown: boolean;
/** Reveal with opacity only — no rise, no blur. */
reduced: boolean;
}
const TimelineContext = React.createContext<TimelineContextValue>({
shown: true,
reduced: true,
});
const TimelineIndexContext = React.createContext(0);
export interface TimelineProps extends React.ComponentProps<"ol"> {
/** Renders everything in place with no reveal animation. */
static?: boolean;
/** Draws the vertical rail connecting the nodes. */
showConnector?: boolean;
}
/**
* A vertical activity timeline. On first scroll into view the rail draws
* in (scaleY from the top — a transform, not a height) and entries
* blur-rise with a 60ms stagger. Both run exactly once; subsequent
* scrolling never re-triggers them. Under reduced motion everything
* reveals with opacity alone.
*/
export function Timeline({
static: isStatic = false,
showConnector = true,
className,
children,
...props
}: TimelineProps) {
const ref = React.useRef<HTMLOListElement>(null);
const inView = useInView(ref, { once: true, margin: "0px 0px -48px 0px" });
const reducedMotion = useReducedMotion() ?? false;
const shown = isStatic || inView;
const reduced = reducedMotion || isStatic;
const items = React.Children.toArray(children);
return (
<TimelineContext.Provider value={{ shown, reduced }}>
<ol ref={ref} data-slot="timeline" className={cn("relative", className)} {...props}>
{showConnector && (
<div
aria-hidden
className={cn(
// 11.5px = 24px node / 2 − 0.5px stroke, so the rail runs dead
// center through every node dot. top/bottom-3 = node radius.
"pointer-events-none absolute top-3 bottom-3 left-[11.5px] w-px origin-top bg-border",
reduced
? cn(
"transition-opacity duration-(--duration-base) ease-(--ease-out)",
shown ? "opacity-100" : "opacity-0",
)
: cn(
"transition-transform duration-(--duration-slow) ease-(--ease-out)",
shown ? "scale-y-100" : "scale-y-0",
),
)}
/>
)}
{items.map((child, i) => (
<TimelineIndexContext.Provider key={i} value={i}>
{child}
</TimelineIndexContext.Provider>
))}
</ol>
</TimelineContext.Provider>
);
}
type TimelineStatus = "default" | "success" | "warning" | "destructive";
// Node tint per semantic status — a filled dot for statuses, quiet for default.
const statusNode: Record<TimelineStatus, string> = {
default: "bg-card text-muted-foreground shadow-border",
success: "bg-success text-success-foreground",
warning: "bg-warning text-warning-foreground",
destructive: "bg-destructive text-destructive-foreground",
};
export interface TimelineItemProps extends React.ComponentProps<"li"> {
/** Node icon on the rail. Defaults to a small dot. */
icon?: React.ReactNode;
/** Semantic status tint for the node. */
status?: TimelineStatus;
}
export function TimelineItem({
icon,
status = "default",
className,
children,
...props
}: TimelineItemProps) {
const { shown, reduced } = React.useContext(TimelineContext);
const index = React.useContext(TimelineIndexContext);
return (
<li
data-slot="timeline-item"
data-status={status}
style={{ transitionDelay: shown ? `${index * 60}ms` : undefined }}
className={cn(
"relative grid grid-cols-[24px_minmax(0,1fr)] gap-x-4 pb-8 last:pb-0",
reduced
? cn(
"transition-opacity duration-(--duration-base) ease-(--ease-out)",
shown ? "opacity-100" : "opacity-0",
)
: cn(
"transition-[opacity,transform,filter] duration-(--duration-base) ease-(--ease-out)",
shown
? "translate-y-0 opacity-100 blur-none"
: "translate-y-3 opacity-0 blur-[4px]",
),
className,
)}
{...props}
>
<div
aria-hidden
className={cn(
"z-[1] flex size-6 items-center justify-center self-start rounded-full [&_svg]:size-3.5",
statusNode[status],
)}
>
{icon ??
(status === "default" ? (
<span className="size-1.5 rounded-full bg-muted-foreground/60" />
) : (
<span className="size-2 rounded-full bg-current" />
))}
</div>
<div className="min-w-0 pt-0.5">{children}</div>
</li>
);
}
export function TimelineTitle({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="timeline-title"
className={cn("text-sm font-medium text-foreground", className)}
{...props}
/>
);
}
export function TimelineDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="timeline-description"
className={cn("mt-0.5 text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export interface TimelineTimeProps extends React.ComponentProps<"time"> {}
export function TimelineTime({ className, ...props }: TimelineTimeProps) {
return (
<time
data-slot="timeline-time"
className={cn(
"mt-1 block text-xs text-muted-foreground/80 tabular-nums",
className,
)}
{...props}
/>
);
}