Long-task status text that rolls through messages every 2.5s beside an elapsed timer, finishing with a check and final message — for AI, export, and deploy waits.
npx shadcn@latest add @paragon/rotating-messages"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useInView,
useReducedMotion,
} from "motion/react";
import { Check, LoaderCircle } from "lucide-react";
import { cn } from "@/lib/utils";
/** Mirrors var(--ease-out) — motion transition arrays can't read CSS custom properties. */
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
/** Mirrors var(--ease-exit). */
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];
function formatElapsed(totalSeconds: number) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
export interface RotatingMessagesProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** Status messages, shown in order. */
messages: string[];
/** Marks the task finished: check icon, final message, timer freezes. */
done?: boolean;
/** Message shown when done. Defaults to the last message. */
doneMessage?: string;
/** Seconds each message holds before rolling to the next. */
interval?: number;
/** Wrap to the first message after the last (otherwise hold the last). */
loop?: boolean;
/** Show the elapsed timer. */
showElapsed?: boolean;
}
/**
* Long-task status text for AI, export, and deploy waits.
*
* Messages roll vertically every 2.5s — the outgoing line exits up at half
* duration while the next rises in (translateY + blur, 300ms) — beside a
* bounded spinner and an elapsed timer in tabular-nums. Setting `done` swaps
* the spinner for a check, shows the final message, and freezes the timer.
*
* The roll pauses offscreen and skips ticks in hidden tabs; screen readers
* get the first message and the completion announcement rather than a
* running commentary. Reduced motion crossfades without movement or blur.
*/
export function RotatingMessages({
messages,
done = false,
doneMessage,
interval = 2.5,
loop = true,
showElapsed = true,
className,
...props
}: RotatingMessagesProps) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref);
const reducedMotion = useReducedMotion() ?? false;
const [index, setIndex] = React.useState(0);
const [elapsed, setElapsed] = React.useState(0);
const startRef = React.useRef<number | null>(null);
// Rotation — pauses offscreen, skips ticks while the tab is hidden.
React.useEffect(() => {
if (done || !inView || messages.length <= 1) return;
const id = window.setInterval(() => {
if (document.hidden) return;
setIndex((i) =>
loop ? (i + 1) % messages.length : Math.min(i + 1, messages.length - 1),
);
}, interval * 1000);
return () => window.clearInterval(id);
}, [done, inView, loop, interval, messages.length]);
// Elapsed timer — real task time, frozen when done.
React.useEffect(() => {
if (done) return;
if (startRef.current === null) startRef.current = performance.now();
const id = window.setInterval(() => {
setElapsed(
Math.floor((performance.now() - (startRef.current ?? 0)) / 1000),
);
}, 1000);
return () => window.clearInterval(id);
}, [done]);
const finalMessage = doneMessage ?? messages[messages.length - 1] ?? "Done";
const message = done ? finalMessage : (messages[index] ?? "");
return (
<div
ref={ref}
className={cn("flex w-full items-center gap-2.5 text-sm", className)}
{...props}
>
{/* Announce start and completion only — not every 2.5s roll. */}
<span role="status" className="sr-only">
{done ? finalMessage : (messages[0] ?? "")}
</span>
<span
aria-hidden
className="flex size-4 shrink-0 items-center justify-center text-muted-foreground"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={done ? "check" : "spinner"}
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={
reducedMotion
? { duration: 0 }
: { type: "spring", duration: 0.3, bounce: 0 }
}
className="flex items-center justify-center"
>
{done ? (
<Check className="size-4 text-foreground" />
) : (
// Bounded loop: only spins for the lifetime of the task.
<LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />
)}
</motion.span>
</AnimatePresence>
</span>
<span aria-hidden className="relative h-5 min-w-0 flex-1 overflow-hidden">
<AnimatePresence initial={false}>
<motion.span
key={done ? "done" : index}
initial={
reducedMotion
? { opacity: 0 }
: { opacity: 0, y: 12, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reducedMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
opacity: 0,
y: -12,
filter: "blur(4px)",
transition: { duration: 0.15, ease: EASE_EXIT },
}
}
transition={{ duration: 0.3, ease: EASE_OUT }}
className="absolute inset-x-0 top-0 truncate text-foreground"
>
{message}
</motion.span>
</AnimatePresence>
</span>
{showElapsed && (
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{formatElapsed(elapsed)}
</span>
)}
</div>
);
}