Menu

Multi-Step Loader

A token-aware loader that animates through a sequence of status messages using spring transitions.

Preview

Live interactive preview.

Installation

Add this component to your project using the CLI:

terminal
npx -y vui-registry-cli-v1@latest add multi-step-loader

Source Code

multi-step-loader.tsx
"use client";

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

const CheckIcon = () => (
  <motion.svg
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={2.5}
    className="w-5 h-5 text-primary"
    initial={{ pathLength: 0, scale: 0.5, opacity: 0 }}
    animate={{ pathLength: 1, scale: 1, opacity: 1 }}
    transition={{ duration: 0.5, ease: "easeOut" }}
  >
    <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  </motion.svg>
);

const SpinnerIcon = () => (
  <motion.div
    animate={{ rotate: 360 }}
    transition={{ repeat: Infinity, duration: 1.2, ease: "linear" }}
    className="w-5 h-5 border-[2px] border-primary/20 border-t-primary rounded-full"
  />
);

const StepItem = ({
  text,
  status,
  distance,
}: {
  text: string;
  status: "completed" | "active" | "waiting";
  distance: number;
}) => {
  return (
    <motion.div
      initial={false}
      animate={{
        y: distance * 64,
        opacity: status === "active" ? 1 : Math.max(1 - Math.abs(distance) * 0.3, 0),
        scale: status === "active" ? 1 : 0.95,
        filter: status === "active" ? "blur(0px)" : `blur(${Math.abs(distance) * 1.5}px)`,
      }}
      transition={{ type: "spring", stiffness: 200, damping: 25, mass: 0.8 }}
      className="absolute inset-x-0 flex items-center justify-center w-full px-4"
    >
      <div className="w-full max-w-[280px] flex items-center justify-start gap-5">
        <div className="relative flex-shrink-0 w-8 h-8 flex items-center justify-center">
          <AnimatePresence mode="wait">
            {status === "completed" && <CheckIcon key="check" />}
            {status === "active" && <SpinnerIcon key="spinner" />}
            {status === "waiting" && (
              <motion.div
                key="dot"
                initial={{ opacity: 0, scale: 0 }}
                animate={{ opacity: 1, scale: 1 }}
                className="w-1.5 h-1.5 bg-foreground/20 rounded-full"
              />
            )}
          </AnimatePresence>
        </div>

        <motion.span
          className={cn(
            "text-[1.1rem] font-medium tracking-tight transition-all duration-300",
            status === "active" ? "text-foreground drop-shadow-sm" : "text-foreground/40"
          )}
        >
          {text}
        </motion.span>
      </div>
    </motion.div>
  );
};

const DEFAULT_LOADING_STATES = [
  { text: "Booting Velocity engine" },
  { text: "Resolving dependencies" },
  { text: "Compiling UI components" },
  { text: "Injecting motion physics" },
  { text: "Hydrating interface" },
];

export const MultiStepLoader = ({
  loadingStates = DEFAULT_LOADING_STATES,
  loading = true,
  duration = 2000,
  loop = true,
  className,
}: {
  loadingStates?: { text: string }[];
  loading?: boolean;
  duration?: number;
  loop?: boolean;
  className?: string;
}) => {
  const [index, setIndex] = useState(0);
  const states = loadingStates && loadingStates.length > 0 ? loadingStates : DEFAULT_LOADING_STATES;

  useEffect(() => {
    if (!loading || !states || states.length === 0) return;
    const interval = setInterval(() => {
      setIndex((prev) => {
        if (loop) {
          return prev < states.length - 1 ? prev + 1 : 0;
        }
        return prev < states.length - 1 ? prev + 1 : prev;
      });
    }, duration);
    return () => clearInterval(interval);
  }, [loading, states, duration, loop]);

  return (
    <AnimatePresence>
      {loading && (
        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0, transition: { duration: 0.3 } }}
          className={cn("absolute inset-0 z-50 flex items-center justify-center bg-background/70 backdrop-blur-md rounded-xl", className)}
        >
          <div className="relative h-[300px] w-full flex items-center justify-center [mask-image:linear-gradient(to_bottom,transparent_0%,black_30%,black_70%,transparent_100%)]">
            {states.map((state, i) => (
              <StepItem
                key={i}
                text={state.text}
                distance={i - index}
                status={i < index ? "completed" : i === index ? "active" : "waiting"}
              />
            ))}
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );
};

export default function VelocityUILoader({
  loadingStates = DEFAULT_LOADING_STATES,
  loading: initialLoading,
  duration = 1800,
  loop = true,
}: {
  loadingStates?: { text: string }[];
  loading?: boolean;
  duration?: number;
  loop?: boolean;
} = {}) {
  const [loading, setLoading] = useState(initialLoading !== undefined ? initialLoading : false);

  useEffect(() => {
    if (initialLoading !== undefined) {
      setLoading(initialLoading);
    }
  }, [initialLoading]);

  const states = loadingStates && loadingStates.length > 0 ? loadingStates : DEFAULT_LOADING_STATES;

  return (
    <div className="flex items-center justify-center min-h-[400px] relative w-full max-w-md">
      <button
        onClick={() => setLoading(true)}
        className="px-6 py-3 rounded-xl font-semibold z-10 bg-foreground text-background hover:scale-105 active:scale-95 transition-all tracking-tight"
      >
        Install Velocity UI
      </button>

      <MultiStepLoader
        loadingStates={states}
        loading={loading}
        duration={duration}
        loop={loop}
      />

      {loading && (
        <button
          className="absolute top-4 right-4 z-[60] text-foreground/50 hover:text-foreground bg-foreground/5 backdrop-blur px-4 py-2 rounded-full text-xs font-semibold tracking-wider uppercase transition-all border border-border/50"
          onClick={() => setLoading(false)}
        >
          Cancel
        </button>
      )}
    </div>
  );
}

Dependencies

  • framer-motion: latest

Props

Component property reference.

NameTypeDefaultDescription
loadingStatesArray<{ text: string }>[]List of messages shown in sequence.
loadingbooleanfalseWhether the loader is active.
durationnumber2000Time in ms between steps.
loopbooleantrueLoop through messages continuously.
classNamestringundefinedAdditional CSS classes.
Context Worth Keeping In Orbit

Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.

I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.

I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.