Skip to content
Loaders

Loaders

MultiStepLoader

Full-screen step loader with ticked stages, a shining active label and WebGL rings.

Not in the CLI: it imports MagicRings and ShinyText, so copy all three files by hand.

packages/ui/src/loaders/MultiStepLoader.tsx · 194 lines

"use client";

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { MagicRings, LOADER_MAGIC_RINGS } from "../animation/MagicRings";
import { ShinyText } from "../animation/ShinyText";

function cn(...parts: Array<string | false | null | undefined>) {
  return parts.filter(Boolean).join(" ");
}

export type LoadingState = { text: string };

const SHINY_ITEM = {
  color: "hsl(45 4% 41% / 0.55)",
  shineColor: "hsl(5 62% 35%)",
} as const;

const PendingIcon = ({ className }: { className?: string }) => (
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={cn("h-6 w-6", className)}>
    <circle cx="12" cy="12" r="9" />
  </svg>
);

const CheckFilled = ({ className }: { className?: string }) => (
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={cn("h-6 w-6", className)}>
    <path fillRule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clipRule="evenodd" />
  </svg>
);

const LoaderCore = ({
  loadingStates,
  value = 0,
  shinyActive = true,
}: {
  loadingStates: LoadingState[];
  value?: number;
  shinyActive?: boolean;
}) => {
  const rowHeight = 40;
  const viewportHeight = Math.min(loadingStates.length, 5) * rowHeight;

  return (
    <div className="relative mx-auto w-full max-w-xl overflow-hidden" style={{ height: `${viewportHeight}px` }}>
      <motion.div
        className="flex flex-col"
        animate={{ y: -(value * rowHeight) }}
        transition={{ duration: 0.45, ease: [0.23, 1, 0.32, 1] }}
      >
        {loadingStates.map((loadingState, index) => {
          const distance = Math.abs(index - value);
          const isActive = value === index;
          const isDone = index < value;
          const isPending = index > value;
          // Pending rows fade with distance, but never below the point where ink
          // on white still clears 4.5:1 (0.6 gives 5.4:1).
          const opacity = isActive ? 1 : isDone ? 0.9 : Math.max(0.68 - distance * 0.06, 0.6);

          return (
            <div key={index} className="flex h-10 items-center gap-2.5 text-left" style={{ opacity }}>
              <div className="shrink-0">
                {isDone ? <CheckFilled className="text-amber-700/80" /> : null}
                {isActive ? <CheckFilled className="text-amber-700" /> : null}
                {isPending ? <PendingIcon className="text-[color:var(--bz-ink,#0a0a0a)]" /> : null}
              </div>
              {isActive && shinyActive ? (
                <ShinyText text={loadingState.text} className="text-base font-medium sm:text-lg" speed={2.2} spread={110} {...SHINY_ITEM} />
              ) : (
                <span
                  className={cn(
                    "text-base sm:text-lg",
                    isActive && "font-medium text-neutral-900",
                    isDone && "text-neutral-800/80",
                    isPending && "text-[color:var(--bz-ink,#0a0a0a)]",
                  )}
                >
                  {loadingState.text}
                </span>
              )}
            </div>
          );
        })}
      </motion.div>
    </div>
  );
};

export type MultiStepLoaderProps = {
  loadingStates: LoadingState[];
  loading?: boolean;
  duration?: number;
  loop?: boolean;
  title?: string;
  subtitle?: string;
  shinyActive?: boolean;
  magicRings?: boolean;
  onComplete?: () => void;
};

export function MultiStepLoader({
  loadingStates,
  loading,
  duration = 2000,
  loop = true,
  title,
  subtitle,
  shinyActive = true,
  magicRings = true,
  onComplete,
}: MultiStepLoaderProps) {
  const [currentState, setCurrentState] = useState(0);
  const onCompleteRef = useRef(onComplete);
  onCompleteRef.current = onComplete;
  const completedRef = useRef(false);

  useEffect(() => {
    if (!loading) {
      setCurrentState(0);
      completedRef.current = false;
      return;
    }
    if (!loop && currentState === loadingStates.length - 1) {
      if (completedRef.current) return;
      completedRef.current = true;
      const done = window.setTimeout(() => onCompleteRef.current?.(), Math.min(duration, 800));
      return () => window.clearTimeout(done);
    }
    const timeout = window.setTimeout(() => {
      setCurrentState((prev) =>
        loop
          ? prev === loadingStates.length - 1
            ? 0
            : prev + 1
          : Math.min(prev + 1, loadingStates.length - 1),
      );
    }, duration);
    return () => window.clearTimeout(timeout);
  }, [currentState, loading, loop, loadingStates.length, duration]);

  return (
    <>
      <style>{`
        .loader-magic-rings-layer {
          position: absolute;
          inset: 0;
          z-index: 0;
          width: 100%;
          height: 100%;
          pointer-events: none;
          overflow: hidden;
        }
        .loader-magic-rings-layer .magic-rings-container {
          position: absolute;
          left: 50%;
          top: 50%;
          width: max(100vw, 100dvh);
          height: max(100vw, 100dvh);
          transform: translate(-50%, -50%);
        }
      `}</style>
      <AnimatePresence mode="wait">
        {loading ? (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            className="fixed inset-0 z-[100] flex h-dvh w-full items-center justify-center overflow-hidden"
            role="status"
            aria-live="polite"
            aria-busy="true"
          >
            {magicRings ? (
              <div className="loader-magic-rings-layer" aria-hidden>
                <MagicRings className="h-full w-full" {...LOADER_MAGIC_RINGS} />
              </div>
            ) : null}
            <div className="absolute inset-0 z-[1] bg-white/60 backdrop-blur-[2px]" />
            <div className="relative z-10 flex w-full max-w-xl flex-col items-center px-6">
              {(title || subtitle) && (
                <div className="w-full rounded-2xl border border-neutral-200/80 bg-white/92 px-5 py-5 text-center shadow-sm backdrop-blur-sm sm:px-6">
                  {title ? <h2 className="text-2xl font-light tracking-tight text-neutral-900 sm:text-3xl">{title}</h2> : null}
                  {subtitle ? <p className="mt-2 text-sm leading-relaxed text-neutral-600 sm:text-base">{subtitle}</p> : null}
                </div>
              )}
              <div className="mt-6 w-full rounded-2xl border border-neutral-200/80 bg-white/90 px-5 py-4 shadow-sm backdrop-blur-sm sm:px-6">
                <LoaderCore value={currentState} loadingStates={loadingStates} shinyActive={shinyActive} />
              </div>
            </div>
          </motion.div>
        ) : null}
      </AnimatePresence>
    </>
  );
}