Multi-Step Loading Sequence with Drawn Checkmarks

A loading card that walks through five named steps. The current step gets a spinning arc and a highlight that slides down the list, finished steps fill in and draw a checkmark stroke by stroke, the connector between them fills, and a progress bar and percentage keep pace. When the last step lands the heading changes and the button wakes up. It is a single narrow column, so it looks the same on a phone.

Code

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";

const steps = [
  { label: "Checking your content", detail: "38 pages, no broken links", took: "0.6s" },
  { label: "Optimising images", detail: "24 photos resized and compressed", took: "1.1s" },
  { label: "Building pages", detail: "Homepage, menu, bookings and 35 more", took: "0.9s" },
  { label: "Securing the connection", detail: "Certificate issued for saltandember.example", took: "0.7s" },
  { label: "Going live", detail: "Rolled out to 14 regions", took: "0.4s" },
];

/** Milliseconds each step stays active, and how long the finished state is held before the demo restarts. */
const STEP_MS = 760;
const HOLD_MS = 2600;

export default function MultiStepLoadingSequenceWithDrawnCheckmarks() {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { amount: 0.3 });
  const [current, setCurrent] = useState(0);
  const done = current >= steps.length;
  const shown = reduce ? steps.length : current;
  const finished = shown >= steps.length;

  useEffect(() => {
    if (reduce || !inView) return;
    const id = window.setTimeout(() => setCurrent((c) => (c >= steps.length ? 0 : c + 1)), done ? HOLD_MS : STEP_MS);
    return () => window.clearTimeout(id);
  }, [current, done, inView, reduce]);

  const pct = Math.round((Math.min(shown, steps.length) / steps.length) * 100);

  return (
    <section className="relative overflow-hidden bg-stone-100 px-5 py-14 text-stone-900 sm:py-20">
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ backgroundImage: "radial-gradient(60% 55% at 50% 0%, rgba(253,186,116,0.55), transparent 70%)" }} />
      <div ref={ref} className="relative mx-auto max-w-md">
        <div className="overflow-hidden rounded-3xl border border-stone-200 bg-white shadow-xl shadow-stone-900/5">
          <div className="px-6 pt-6 sm:px-7 sm:pt-7">
            <div className="flex items-start justify-between gap-4">
              <div>
                <p className="text-xs font-semibold uppercase tracking-widest text-orange-700">Salt and Ember</p>
                <h2 className="mt-1.5 text-2xl font-semibold tracking-tight" aria-live="polite">
                  {finished ? "Your site is live" : "Publishing your site"}
                </h2>
              </div>
              <p className="pt-1 font-mono text-2xl font-semibold tabular-nums text-stone-900">
                {pct}
                <span className="text-base text-stone-500">%</span>
              </p>
            </div>
            <div className="mt-5 h-1.5 overflow-hidden rounded-full bg-stone-100" role="progressbar" aria-label="Publishing progress" aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct}>
              <motion.div
                className="h-full w-full origin-left rounded-full bg-gradient-to-r from-orange-500 to-amber-400"
                initial={false}
                animate={{ scaleX: pct / 100 }}
                transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 120, damping: 22 }}
              />
            </div>
          </div>

          <ol className="relative mt-4 px-3 pb-3 sm:px-4">
            {steps.map((s, i) => {
              const state = i < shown ? "done" : i === shown ? "active" : "todo";
              return (
                <li key={s.label} className="relative flex gap-4 rounded-2xl px-3 py-3">
                  {state === "active" && (
                    <motion.span layoutId="multi-step-loading-row" aria-hidden="true" className="absolute inset-0 rounded-2xl bg-orange-50" transition={{ type: "spring", stiffness: 380, damping: 34 }} />
                  )}
                  {i < steps.length - 1 && (
                    <span aria-hidden="true" className="absolute left-[27px] top-[44px] h-[calc(100%-34px)] w-0.5 overflow-hidden rounded-full bg-stone-200">
                      <motion.span className="block h-full w-full origin-top bg-stone-900" initial={false} animate={{ scaleY: state === "done" ? 1 : 0 }} transition={{ duration: reduce ? 0 : 0.35, ease: "easeOut" }} />
                    </span>
                  )}
                  <span className="relative mt-0.5 flex h-8 w-8 flex-none items-center justify-center">
                    <motion.span
                      className="absolute inset-0 rounded-full"
                      initial={false}
                      animate={{
                        backgroundColor: state === "done" ? "#1c1917" : "#ffffff",
                        boxShadow: state === "todo" ? "inset 0 0 0 2px #d6d3d1" : state === "active" ? "inset 0 0 0 2px #fed7aa" : "inset 0 0 0 2px #1c1917",
                        scale: state === "done" ? [1, 1.18, 1] : 1,
                      }}
                      transition={{ duration: reduce ? 0 : 0.4 }}
                    />
                    {state === "active" && (
                      <motion.svg viewBox="0 0 32 32" className="absolute inset-0 h-full w-full" aria-hidden="true" animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 0.8, ease: "linear" }}>
                        <circle cx="16" cy="16" r="15" fill="none" stroke="#ea580c" strokeWidth="2" strokeLinecap="round" strokeDasharray="30 100" />
                      </motion.svg>
                    )}
                    <svg viewBox="0 0 24 24" className="relative h-4 w-4" fill="none" aria-hidden="true">
                      <motion.path
                        d="M5 12.5l4.5 4.5L19 7.5"
                        stroke="#ffffff"
                        strokeWidth="3"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        initial={false}
                        animate={{ pathLength: state === "done" ? 1 : 0, opacity: state === "done" ? 1 : 0 }}
                        transition={{ pathLength: { duration: reduce ? 0 : 0.35, delay: reduce ? 0 : 0.12, ease: "easeOut" }, opacity: { duration: 0.01 } }}
                      />
                    </svg>
                    {state === "todo" && <span className="absolute text-xs font-semibold text-stone-500">{i + 1}</span>}
                  </span>
                  <div className="relative min-w-0 flex-1">
                    <div className="flex items-baseline justify-between gap-3">
                      <p className={`text-[15px] font-semibold transition-colors duration-300 ${state === "todo" ? "text-stone-500" : "text-stone-900"}`}>{s.label}</p>
                      <AnimatePresence>
                        {state === "done" && (
                          <motion.span key="took" className="flex-none font-mono text-xs text-stone-500" initial={reduce ? false : { opacity: 0, x: 8 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0 }} transition={{ delay: 0.2 }}>
                            {s.took}
                          </motion.span>
                        )}
                      </AnimatePresence>
                    </div>
                    <p className={`mt-0.5 truncate text-sm transition-colors duration-300 ${state === "todo" ? "text-stone-500" : "text-stone-600"}`}>
                      {state === "active" ? "Working on it" : state === "todo" ? "Waiting" : s.detail}
                    </p>
                  </div>
                </li>
              );
            })}
          </ol>

          <div className="flex items-center justify-between gap-4 border-t border-stone-200 bg-stone-50 px-6 py-4 sm:px-7">
            <p className="min-w-0 truncate text-sm text-stone-600">{finished ? "Done in 3.7s" : `Step ${Math.min(shown + 1, steps.length)} of ${steps.length}`}</p>
            <motion.a
              href="#"
              aria-disabled={!finished}
              tabIndex={finished ? 0 : -1}
              className="inline-flex min-h-[40px] flex-none items-center gap-1.5 rounded-full bg-stone-900 px-4 text-sm font-semibold text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-500 focus-visible:ring-offset-2"
              initial={false}
              animate={{ opacity: finished ? 1 : 0.35, scale: finished ? [1, 1.06, 1] : 1 }}
              transition={{ duration: reduce ? 0 : 0.45 }}
            >
              View site
              <svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M4 10h12M11 5l5 5-5 5" />
              </svg>
            </motion.a>
          </div>
        </div>
        <p className="mt-5 text-center text-sm text-stone-600">Five steps, one line of state. The demo restarts on its own.</p>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/multi-step-loading-sequence-with-drawn-checkmarks.json

When to use it

How to customize it

Accessibility

Questions

How is the checkmark drawn?

The check is an SVG path. Framer Motion animates its `pathLength` from 0 to 1, which reveals the stroke from its first point to its last. A short delay lets the circle fill first, so the tick appears to be written onto it.

How does the highlight slide between rows?

Only the active row renders the tinted background, and it carries a `layoutId`. When the active row changes, Framer Motion animates the same element from the old position to the new one with a spring.

Can I connect this to a real task?

Yes. The whole card reads from one number, `current`. Set it to the count of completed stages from your upload, build or request, and remove the demo timer.

How many steps work best?

Three to six. Fewer than three reads as a plain spinner, and more than six makes the wait feel longer than it is. Group small jobs under one label.

Does the loop keep running off screen?

No. The timer only runs while the card is in view, using `useInView`, and it is cleared whenever the component unmounts.

Related: Six CSS Loaders: Orbit, Bars, Morph, Ring, Pulse and Shimmer, Multi-Step Signup Form with Step Indicator, Dark Toast Notification Stack with Undo and Progress.

More loaders or all free UI components. Start for free to have a full site built around it.

More in Loaders

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap