my/ui

Command Palette

Search for a command to run...

All components

Multi Step Loader

loaders

Full-screen overlay loader that walks through sequential steps with timing, optional async steps, completion callbacks, and animated step icons. Vue watch/emit/timer logic converted to useEffect + useRef for timer management. Framer-motion AnimatePresence replaces Vue Transition.

responsive · 600px

Install

Same command in any shadcn project — React (Vite/CRA), Next.js, Remix, Astro, and more:

$npx shadcn@latest add https://your-domain/r/inspira-multi-step-loader.json

Usage

"use client";

import { useState } from "react";
import { MultiStepLoader } from "@/registry/inspira-react/multi-step-loader";

const steps = [
  { text: "Initializing project...", afterText: "Project initialized!" },
  { text: "Installing dependencies...", afterText: "Dependencies installed." },
  { text: "Configuring environment...", afterText: "Config ready." },
  { text: "Building assets...", afterText: "Build complete." },
  { text: "Deploying to production...", afterText: "Live! 🎉" },
];

export default function MultiStepLoaderDemo() {
  const [loading, setLoading] = useState(false);

  return (
    <div className="flex min-h-32 flex-col items-center justify-center gap-4 p-8">
      <MultiStepLoader
        steps={steps}
        loading={loading}
        defaultDuration={1200}
        onComplete={() => setTimeout(() => setLoading(false), 800)}
        onClose={() => setLoading(false)}
      />
      <button
        onClick={() => setLoading(true)}
        className="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
      >
        Start Loader
      </button>
    </div>
  );
}

Component source

"use client";

import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";

export interface LoaderStep {
  text: string;
  afterText?: string;
  async?: boolean;
  duration?: number;
  action?: () => void;
}

interface MultiStepLoaderProps {
  steps: LoaderStep[];
  loading?: boolean;
  defaultDuration?: number;
  preventClose?: boolean;
  onStateChange?: (index: number) => void;
  onComplete?: () => void;
  onClose?: () => void;
}

export function MultiStepLoader({
  steps,
  loading = false,
  defaultDuration = 1500,
  preventClose = false,
  onStateChange,
  onComplete,
  onClose,
}: MultiStepLoaderProps) {
  const [currentState, setCurrentState] = useState(0);
  const [isLastStepComplete, setIsLastStepComplete] = useState(false);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  function clearTimer() {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
  }

  async function proceedToNextStep(stateIndex: number) {
    const currentStep = steps[stateIndex];
    if (!currentStep) return;

    if (typeof currentStep.action === "function") {
      await currentStep.action();
    }

    if (stateIndex < steps.length - 1) {
      const next = stateIndex + 1;
      setCurrentState(next);
      onStateChange?.(next);
      processStep(next);
    } else {
      setIsLastStepComplete(true);
      onComplete?.();
    }
  }

  function processStep(stateIndex: number) {
    clearTimer();
    const step = steps[stateIndex];
    if (!step) return;
    const duration = step.duration ?? defaultDuration;
    if (!step.async) {
      timerRef.current = setTimeout(() => {
        proceedToNextStep(stateIndex);
      }, duration);
    }
  }

  // Watch async -> false transition
  useEffect(() => {
    const step = steps[currentState];
    if (!step || step.async) return;
    // If async just turned false for the current step, schedule
    const duration = step.duration ?? defaultDuration;
    timerRef.current = setTimeout(() => {
      proceedToNextStep(currentState);
    }, duration);
    return clearTimer;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [steps[currentState]?.async]);

  useEffect(() => {
    if (loading) {
      setCurrentState(0);
      setIsLastStepComplete(false);
      processStep(0);
    } else {
      clearTimer();
    }
    return clearTimer;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [loading]);

  return (
    <AnimatePresence>
      {loading && steps.length > 0 && (
        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.3 }}
          className="fixed inset-0 z-[100] flex size-full items-center justify-center backdrop-blur-2xl"
        >
          {/* Close button */}
          {!preventClose && (
            <button
              onClick={onClose}
              className={cn(
                "bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring",
                "absolute top-4 right-4 z-[101] inline-flex h-9 items-center justify-center",
                "rounded-md px-3 text-sm font-medium whitespace-nowrap transition-colors",
                "focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
                "disabled:pointer-events-none disabled:opacity-50",
              )}
            >
              <svg
                xmlns="http://www.w3.org/2000/svg"
                fill="none"
                viewBox="0 0 24 24"
                strokeWidth="1.5"
                stroke="currentColor"
                className="size-6"
              >
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  d="M6 18 18 6M6 6l12 12"
                />
              </svg>
            </button>
          )}

          <div className="relative h-96">
            <div className="relative mx-auto mt-40 flex max-w-xl flex-col justify-start">
              {steps.map((step, index) => (
                <div key={index}>
                  <div
                    className="mb-4 flex items-center gap-2 text-left transition-all duration-300 ease-in-out"
                    style={{
                      opacity:
                        index === currentState
                          ? 1
                          : Math.max(1 - Math.abs(index - currentState) * 0.2, 0),
                      transform: `translateY(${-(currentState * 40)}px)`,
                    }}
                  >
                    {/* Completed icon */}
                    {(index < currentState ||
                      (index === steps.length - 1 &&
                        index === currentState &&
                        isLastStepComplete)) && (
                      <svg
                        xmlns="http://www.w3.org/2000/svg"
                        viewBox="0 0 24 24"
                        fill="currentColor"
                        className="text-primary size-6"
                      >
                        <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>
                    )}

                    {/* Loading spinner */}
                    {index === currentState &&
                      (!isLastStepComplete || index !== steps.length - 1) && (
                        <svg
                          xmlns="http://www.w3.org/2000/svg"
                          viewBox="0 0 24 24"
                          fill="currentColor"
                          className="text-primary size-6 animate-spin"
                        >
                          <path
                            fillRule="evenodd"
                            d="M4.755 10.059a7.5 7.5 0 0 1 12.548-3.364l1.903 1.903h-3.183a.75.75 0 1 0 0 1.5h4.992a.75.75 0 0 0 .75-.75V4.356a.75.75 0 0 0-1.5 0v3.18l-1.9-1.9A9 9 0 0 0 3.306 9.67a.75.75 0 1 0 1.45.388Zm15.408 3.352a.75.75 0 0 0-.919.53 7.5 7.5 0 0 1-12.548 3.364l-1.902-1.903h3.183a.75.75 0 0 0 0-1.5H2.984a.75.75 0 0 0-.75.75v4.992a.75.75 0 0 0 1.5 0v-3.18l1.9 1.9a9 9 0 0 0 15.059-4.035.75.75 0 0 0-.53-.918Z"
                            clipRule="evenodd"
                          />
                        </svg>
                      )}

                    {/* Pending icon */}
                    {index > currentState && (
                      <svg
                        xmlns="http://www.w3.org/2000/svg"
                        fill="none"
                        viewBox="0 0 24 24"
                        strokeWidth="1.5"
                        stroke="currentColor"
                        className="size-6 text-black opacity-50 dark:text-white"
                      >
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
                        />
                      </svg>
                    )}

                    <div className="flex flex-col">
                      <span
                        className={cn(
                          "text-lg text-black dark:text-white",
                          index > currentState && "opacity-50",
                        )}
                      >
                        {step.text}
                      </span>

                      <AnimatePresence>
                        {step.afterText &&
                          (index < currentState ||
                            (index === steps.length - 1 &&
                              index === currentState &&
                              isLastStepComplete)) && (
                            <motion.span
                              key="afterText"
                              initial={{ opacity: 0, y: -4 }}
                              animate={{ opacity: 1, y: 0 }}
                              transition={{ duration: 0.3 }}
                              className="mt-1 text-sm text-gray-500 dark:text-gray-400"
                            >
                              {step.afterText}
                            </motion.span>
                          )}
                      </AnimatePresence>
                    </div>
                  </div>
                </div>
              ))}
            </div>
          </div>

          <div className="absolute inset-x-0 bottom-0 -z-10 h-full bg-white bg-gradient-to-t dark:bg-black" />
        </motion.div>
      )}
    </AnimatePresence>
  );
}