Loaders & Skeletons

Rotating Messages

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.

Install

npx shadcn@latest add @paragon/rotating-messages

rotating-messages.tsx

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