"use client";
import * as React from "react";
import { motion, useInView } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export type TimelineStatus = "done" | "active" | "pending" | "error";
export interface TimelineItem {
id: string;
title: React.ReactNode;
description?: React.ReactNode;
/** Display text for the time, e.g. "09:41" or "12s". */
time?: string;
/** Machine-readable value for <time dateTime> ("2026-10-13T09:41", "PT12S"). */
dateTime?: string;
status: TimelineStatus;
/** Replaces the status glyph inside the marker. */
icon?: React.ReactNode;
}
export interface ActivityTimelineProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
items: TimelineItem[];
/** Accessible name of the list. */
label?: string;
/** Delay between items as they reveal, in ms. */
stagger?: number;
/** Reveal items and fill the line the first time the list scrolls into view. */
revealOnView?: boolean;
/** Clamp descriptions to this many lines with a Show more toggle. 0 never clamps. */
collapsedLines?: number;
density?: "comfortable" | "compact";
showTime?: boolean;
/** Line fill, active marker and focus colour (any CSS colour). */
accent?: string;
/** Text conveying each status to assistive tech (and on the active/error badges). */
statusLabels?: Partial<Record<TimelineStatus, string>>;
}
const DEFAULT_STATUS_LABELS: Record<TimelineStatus, string> = {
done: "Completed",
active: "In progress",
pending: "Pending",
error: "Failed",
};
function subscribeVisibility(callback: () => void) {
document.addEventListener("visibilitychange", callback);
return () => document.removeEventListener("visibilitychange", callback);
}
/** Description that clamps to N lines and offers a toggle only when it actually overflows. */
function Description({ id, lines, children }: { id: string; lines: number; children: React.ReactNode }) {
const ref = React.useRef<HTMLDivElement>(null);
const [expanded, setExpanded] = React.useState(false);
const [overflowing, setOverflowing] = React.useState(false);
React.useEffect(() => {
const el = ref.current;
if (!el || lines <= 0 || expanded) return;
// Measured in an observer callback, so it follows container and font changes.
const ro = new ResizeObserver(() => setOverflowing(el.scrollHeight > el.clientHeight + 1));
ro.observe(el);
return () => ro.disconnect();
}, [lines, expanded]);
const clamped = lines > 0 && !expanded;
return (
<>
<div
ref={ref}
id={id}
className="mt-1 text-[13px] leading-relaxed text-zinc-600 dark:text-zinc-400"
style={clamped ? { display: "-webkit-box", WebkitBoxOrient: "vertical", WebkitLineClamp: lines, overflow: "hidden" } : undefined}
>
{children}
</div>
{lines > 0 && (overflowing || expanded) && (
<button
type="button"
aria-expanded={expanded}
aria-controls={id}
onClick={() => setExpanded((e) => !e)}
className="mt-1 rounded font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 underline decoration-zinc-900/20 underline-offset-4 outline-none transition-colors hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-[var(--at-accent)] dark:text-zinc-400 dark:decoration-white/20 dark:hover:text-white"
>
{expanded ? "Show less" : "Show more"}
</button>
)}
</>
);
}
function StatusGlyph({ status }: { status: TimelineStatus }) {
if (status === "done")
return (
<svg viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
<path d="M2.5 6.2 5 8.5l4.5-5" />
</svg>
);
if (status === "error")
return (
<svg viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<path d="M3.5 3.5l5 5M8.5 3.5l-5 5" />
</svg>
);
if (status === "active") return <span className="size-2 rounded-full bg-[var(--at-accent)]" />;
return <span className="size-1.5 rounded-full bg-zinc-900/20 dark:bg-white/25" />;
}
/**
* A vertical activity timeline as a semantic ordered list. The connecting line fills segment by
* segment up to the current item, items reveal with a stagger the first time they scroll into
* view, the active marker pulses while visible, and long descriptions collapse.
*/
export function ActivityTimeline({
items,
label = "Activity",
stagger = 80,
revealOnView = true,
collapsedLines = 2,
density = "comfortable",
showTime = true,
accent = "#FF7448",
statusLabels,
className = "",
style,
...props
}: ActivityTimelineProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const listRef = React.useRef<HTMLOListElement>(null);
const seen = useInView(listRef, { once: true, amount: 0.15 });
const onScreen = useInView(listRef);
const hidden = React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);
const labels = { ...DEFAULT_STATUS_LABELS, ...statusLabels };
const animateIn = revealOnView && !reduceMotion;
const shown = !revealOnView || seen;
// The line fills up to the active (or failed) item; with neither, up to the last finished one.
let reach = items.findIndex((i) => i.status === "active" || i.status === "error");
if (reach === -1) {
items.forEach((it, i) => {
if (it.status === "done") reach = i;
});
}
// The first reveal fills segments one after another; later changes animate immediately.
const [introDone, setIntroDone] = React.useState(false);
React.useEffect(() => {
if (!shown || introDone) return;
const t = setTimeout(() => setIntroDone(true), 300 + items.length * Math.max(stagger, 60) + 400);
return () => clearTimeout(t);
}, [shown, introDone, items.length, stagger]);
const compact = density === "compact";
const marker = compact ? 22 : 28;
const pulse = onScreen && !hidden && !reduceMotion;
return (
<div className={`w-full ${className}`} style={{ ["--at-accent" as string]: accent, ...style }} {...props}>
<ol ref={listRef} aria-label={label} className="relative">
{items.map((item, i) => {
const isLast = i === items.length - 1;
const segmentFilled = shown && i < reach;
const descId = `${uid}-desc-${item.id}`;
const toneMarker =
item.status === "done"
? "border-transparent bg-[var(--at-accent)] text-white dark:text-zinc-950"
: item.status === "active"
? "border-[var(--at-accent)] bg-white dark:bg-zinc-950"
: item.status === "error"
? "border-transparent bg-rose-500 text-white dark:bg-rose-400 dark:text-zinc-950"
: "border-zinc-900/15 bg-white text-zinc-400 dark:border-white/15 dark:bg-zinc-950 dark:text-zinc-500";
return (
<motion.li
key={item.id}
aria-current={item.status === "active" ? "step" : undefined}
initial={animateIn ? { opacity: 0, y: 10 } : false}
animate={shown ? { opacity: 1, y: 0 } : { opacity: 0, y: 10 }}
transition={{ duration: 0.45, ease: [0.2, 0.7, 0.2, 1], delay: animateIn && !introDone ? (i * stagger) / 1000 : 0 }}
className={`relative grid grid-cols-[auto_minmax(0,1fr)] ${compact ? "gap-x-3" : "gap-x-4"} ${isLast ? "" : compact ? "pb-4" : "pb-7"}`}
>
{/* Connector to the next item: a hairline track with an accent fill. */}
{!isLast && (
<span
aria-hidden="true"
className="absolute w-px overflow-hidden bg-zinc-900/10 dark:bg-white/10"
style={{ left: marker / 2 - 0.5, top: marker + 4, bottom: 4 }}
>
<motion.span
className="absolute inset-0 origin-top bg-[var(--at-accent)]"
initial={false}
animate={{ scaleY: segmentFilled ? 1 : 0 }}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.5, ease: [0.4, 0, 0.2, 1], delay: introDone ? 0 : 0.25 + (i * Math.max(stagger, 60)) / 1000 }
}
/>
</span>
)}
<span
aria-hidden="true"
className={`relative z-[1] grid shrink-0 place-items-center rounded-full border transition-colors duration-300 motion-reduce:transition-none ${toneMarker}`}
style={{ width: marker, height: marker }}
>
{item.status === "active" && (
<span
className={`absolute -inset-px rounded-full border border-[var(--at-accent)] ${pulse ? "animate-ping [animation-duration:1.6s]" : "opacity-0"}`}
/>
)}
{item.icon ? <span className="grid size-3.5 place-items-center [&>svg]:size-full">{item.icon}</span> : <StatusGlyph status={item.status} />}
</span>
<div className={`min-w-0 ${compact ? "pt-0.5" : "pt-1"}`}>
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
<p
className={`min-w-0 font-medium tracking-[-0.01em] ${compact ? "text-[13.5px]" : "text-[14.5px]"} ${
item.status === "pending" ? "text-zinc-500 dark:text-zinc-400" : "text-zinc-950 dark:text-zinc-50"
}`}
>
<span className="sr-only">{labels[item.status]}: </span>
{item.title}
{(item.status === "active" || item.status === "error") && (
<span
aria-hidden="true"
className={`ml-2 inline-block translate-y-[-1px] rounded-full px-1.5 py-px align-middle font-mono text-[9px] uppercase tracking-[0.14em] ${
item.status === "error"
? "bg-rose-500/10 text-rose-600 dark:text-rose-400"
: "bg-[color-mix(in_srgb,var(--at-accent)_14%,transparent)] text-zinc-800 dark:text-zinc-100"
}`}
>
{labels[item.status]}
</span>
)}
</p>
{showTime && item.time && (
<span className="shrink-0 font-mono text-[10px] tabular-nums uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400">
{item.dateTime ? <time dateTime={item.dateTime}>{item.time}</time> : item.time}
</span>
)}
</div>
{item.description && (
<Description id={descId} lines={collapsedLines}>
{item.description}
</Description>
)}
</div>
</motion.li>
);
})}
</ol>
</div>
);
}