Web InnoventixFreeCode

Progress Stepper

Original · free

stepper with progress fill

byWeb InnoventixReact + Tailwind
stepprogresssteppers
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/step-progress.json
step-progress.tsx
"use client";

import { useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Step = {
  id: string;
  label: string;
  title: string;
  detail: string;
  points: string[];
};

const steps: Step[] = [
  {
    id: "account",
    label: "Account",
    title: "Create your account",
    detail:
      "Set your workspace name and pick the region where your data will live. You can rename the workspace anytime, but the region is locked once it's provisioned.",
    points: [
      "Claim a URL like acme.workspace.app",
      "Choose US, EU, or AP data residency",
      "Verify your work email to continue",
    ],
  },
  {
    id: "team",
    label: "Team",
    title: "Invite your team",
    detail:
      "Add the people you'll work alongside and give each a starting role. Invites stay valid for seven days and a seat is only billed once someone accepts.",
    points: [
      "Bulk-invite by pasting comma-separated emails",
      "Assign Admin, Editor, or Viewer per person",
      "Skip for now and invite from Settings later",
    ],
  },
  {
    id: "data",
    label: "Data",
    title: "Connect a data source",
    detail:
      "Link a tool you already use so your dashboards fill in on day one. Every connection is read-only by default and can be revoked whenever you want.",
    points: [
      "One-click OAuth for Postgres, Stripe, and HubSpot",
      "Or upload a CSV to get moving in seconds",
      "The first sync usually finishes in under two minutes",
    ],
  },
  {
    id: "billing",
    label: "Billing",
    title: "Choose a plan",
    detail:
      "Start the 14-day trial with no card required. Lock in annual pricing now, or decide later without losing a single thing you've set up.",
    points: [
      "Starter, Growth, and Scale tiers available",
      "Switch or cancel anytime, prorated to the day",
      "Nonprofit and education discounts on request",
    ],
  },
  {
    id: "review",
    label: "Launch",
    title: "Review & launch",
    detail:
      "Everything checks out. Launching provisions your workspace, sends the pending invites, and kicks off the first data sync in the background.",
    points: [
      "Workspace name and region confirmed",
      "Team invitations queued to send",
      "Data source connected and ready to sync",
    ],
  },
];

export default function StepProgress() {
  const reduce = useReducedMotion();
  const [current, setCurrent] = useState(0);
  const [maxReached, setMaxReached] = useState(0);
  const [launched, setLaunched] = useState(false);
  const stepRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const last = steps.length - 1;
  const fillPercent = launched ? 100 : (current / last) * 100;
  const rounded = Math.round(fillPercent);

  const goTo = (i: number) => {
    if (launched || i < 0 || i > maxReached) return;
    setCurrent(i);
  };

  const next = () => {
    if (current < last) {
      const n = current + 1;
      setCurrent(n);
      setMaxReached((m) => Math.max(m, n));
    } else {
      setLaunched(true);
    }
  };

  const back = () => setCurrent((c) => Math.max(0, c - 1));

  const reset = () => {
    setLaunched(false);
    setCurrent(0);
    setMaxReached(0);
  };

  const onListKeyDown = (e: KeyboardEvent<HTMLOListElement>) => {
    if (launched) return;
    let target = current;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        target = Math.min(maxReached, current + 1);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        target = Math.max(0, current - 1);
        break;
      case "Home":
        target = 0;
        break;
      case "End":
        target = maxReached;
        break;
      default:
        return;
    }
    e.preventDefault();
    setCurrent(target);
    stepRefs.current[target]?.focus();
  };

  const contentTransition = reduce
    ? { duration: 0 }
    : { duration: 0.3, ease: [0.22, 1, 0.36, 1] as const };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes sp_pop {
          0% { transform: scale(0.4); opacity: 0; }
          60% { transform: scale(1.15); }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes sp_ring {
          0% { transform: scale(1); opacity: 0.5; }
          70% { transform: scale(2); opacity: 0; }
          100% { transform: scale(2); opacity: 0; }
        }
        @keyframes sp_sheen {
          0% { transform: translateX(-120%); }
          60%, 100% { transform: translateX(320%); }
        }
        .sp-check { animation: sp_pop 340ms cubic-bezier(0.34, 1.56, 0.64, 1) both; }
        .sp-ring { animation: sp_ring 1900ms ease-out infinite; }
        .sp-sheen { animation: sp_sheen 2600ms ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .sp-check, .sp-ring, .sp-sheen { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <header className="mb-12 text-center sm:text-left">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Onboarding
          </p>
          <h2 className="mt-2 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
            Set up your workspace
          </h2>
          <p className="mt-2 max-w-xl text-sm text-slate-600 sm:text-base dark:text-slate-400">
            Five quick steps and you're live. Move with the buttons or the step
            markers, and the bar fills as you go.
          </p>
        </header>

        {/* Stepper */}
        <div className="relative pb-9">
          {/* Rail track */}
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-x-5 top-5 h-1.5 -translate-y-1/2 rounded-full bg-slate-200 dark:bg-slate-800"
          />
          {/* Rail fill (progressbar) */}
          <div
            role="progressbar"
            aria-label="Setup progress"
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={rounded}
            className="pointer-events-none absolute inset-x-5 top-5 h-1.5 -translate-y-1/2 overflow-hidden rounded-full"
          >
            <motion.div
              initial={false}
              animate={{ width: `${fillPercent}%` }}
              transition={
                reduce ? { duration: 0 } : { type: "spring", stiffness: 140, damping: 24 }
              }
              className="relative h-full overflow-hidden rounded-full bg-gradient-to-r from-indigo-500 via-violet-500 to-sky-400"
            >
              <span
                aria-hidden="true"
                className="sp-sheen absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/60 to-transparent"
              />
            </motion.div>
          </div>

          {/* Step markers */}
          <ol
            onKeyDown={onListKeyDown}
            aria-label="Setup steps"
            className="relative flex justify-between"
          >
            {steps.map((step, i) => {
              const isComplete = launched || i < current;
              const isCurrent = !launched && i === current;
              const isReachable = !launched && i <= maxReached;

              return (
                <li key={step.id} className="relative flex w-10 flex-col items-center">
                  <span className="relative flex h-10 w-10 items-center justify-center">
                    {isCurrent && !reduce && (
                      <span
                        aria-hidden="true"
                        className="sp-ring pointer-events-none absolute inset-0 rounded-full bg-indigo-400/50 dark:bg-indigo-500/40"
                      />
                    )}
                    <button
                      type="button"
                      ref={(el) => {
                        stepRefs.current[i] = el;
                      }}
                      onClick={() => goTo(i)}
                      disabled={!isReachable}
                      tabIndex={isCurrent || (launched && i === last) ? 0 : -1}
                      aria-current={isCurrent ? "step" : undefined}
                      aria-label={`Step ${i + 1} of ${steps.length}: ${step.title}${
                        isComplete ? ", completed" : isCurrent ? ", current" : ", not yet available"
                      }`}
                      className={[
                        "relative z-10 flex h-10 w-10 items-center justify-center rounded-full border text-sm font-semibold transition-colors duration-200",
                        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950",
                        isReachable ? "cursor-pointer" : "cursor-not-allowed",
                        isComplete
                          ? "border-emerald-500 bg-emerald-500 text-white"
                          : isCurrent
                            ? "border-indigo-600 bg-white text-indigo-700 shadow-sm dark:bg-slate-900 dark:text-indigo-300"
                            : "border-slate-300 bg-white text-slate-400 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-500",
                      ].join(" ")}
                    >
                      {isComplete ? (
                        <svg
                          viewBox="0 0 24 24"
                          fill="none"
                          className="sp-check h-5 w-5"
                          aria-hidden="true"
                        >
                          <path
                            d="M20 6 9 17l-5-5"
                            stroke="currentColor"
                            strokeWidth={2.6}
                            strokeLinecap="round"
                            strokeLinejoin="round"
                          />
                        </svg>
                      ) : (
                        <span>{i + 1}</span>
                      )}
                    </button>
                  </span>
                  <span
                    className={[
                      "pointer-events-none absolute left-1/2 top-12 w-16 -translate-x-1/2 text-center text-[10px] font-medium sm:w-24 sm:text-xs",
                      isComplete
                        ? "text-emerald-600 dark:text-emerald-400"
                        : isCurrent
                          ? "text-slate-900 dark:text-white"
                          : "text-slate-400 dark:text-slate-500",
                    ].join(" ")}
                  >
                    {step.label}
                  </span>
                </li>
              );
            })}
          </ol>
        </div>

        <p className="sr-only" aria-live="polite">
          {launched
            ? "Workspace launched. Setup complete."
            : `Step ${current + 1} of ${steps.length}: ${steps[current].title}`}
        </p>

        {/* Panel */}
        <div className="mt-6 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900">
          <AnimatePresence mode="wait" initial={false}>
            {launched ? (
              <motion.div
                key="done"
                initial={{ opacity: 0, y: reduce ? 0 : 8 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: reduce ? 0 : -8 }}
                transition={contentTransition}
                className="flex flex-col items-center gap-4 py-6 text-center"
              >
                <span className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500 text-white">
                  <svg viewBox="0 0 24 24" fill="none" className="sp-check h-7 w-7" aria-hidden="true">
                    <path
                      d="M20 6 9 17l-5-5"
                      stroke="currentColor"
                      strokeWidth={2.6}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                  </svg>
                </span>
                <div>
                  <h3 className="text-xl font-bold text-slate-900 dark:text-white">
                    Your workspace is live
                  </h3>
                  <p className="mx-auto mt-2 max-w-md text-sm text-slate-600 dark:text-slate-400">
                    Invitations are on their way and your first sync is running.
                    You'll get an email the moment your dashboards are ready.
                  </p>
                </div>
                <button
                  type="button"
                  onClick={reset}
                  className="mt-2 inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                >
                  <svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
                    <path
                      d="M3 12a9 9 0 1 0 3-6.7L3 8m0-5v5h5"
                      stroke="currentColor"
                      strokeWidth={1.8}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                  </svg>
                  Start over
                </button>
              </motion.div>
            ) : (
              <motion.div
                key={current}
                initial={{ opacity: 0, y: reduce ? 0 : 8 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: reduce ? 0 : -8 }}
                transition={contentTransition}
              >
                <p className="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
                  Step {current + 1} of {steps.length}
                </p>
                <h3 className="mt-1 text-xl font-bold text-slate-900 dark:text-white">
                  {steps[current].title}
                </h3>
                <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                  {steps[current].detail}
                </p>
                <ul className="mt-5 space-y-2.5">
                  {steps[current].points.map((point) => (
                    <li key={point} className="flex items-start gap-3 text-sm text-slate-700 dark:text-slate-300">
                      <svg
                        viewBox="0 0 24 24"
                        fill="none"
                        className="mt-0.5 h-4 w-4 flex-none text-indigo-500 dark:text-indigo-400"
                        aria-hidden="true"
                      >
                        <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth={1.6} />
                        <path
                          d="m8.5 12 2.3 2.3 4.7-4.6"
                          stroke="currentColor"
                          strokeWidth={1.8}
                          strokeLinecap="round"
                          strokeLinejoin="round"
                        />
                      </svg>
                      <span>{point}</span>
                    </li>
                  ))}
                </ul>
              </motion.div>
            )}
          </AnimatePresence>
        </div>

        {/* Controls */}
        {!launched && (
          <div className="mt-8 flex items-center justify-between gap-4">
            <button
              type="button"
              onClick={back}
              disabled={current === 0}
              className="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950"
            >
              <svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
                <path
                  d="M15 18 9 12l6-6"
                  stroke="currentColor"
                  strokeWidth={1.9}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
              Back
            </button>

            <span
              aria-hidden="true"
              className="text-sm font-medium tabular-nums text-slate-500 dark:text-slate-400"
            >
              {rounded}% complete
            </span>

            <button
              type="button"
              onClick={next}
              className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-indigo-500 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950"
            >
              {current === last ? "Launch workspace" : "Continue"}
              <svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
                <path
                  d="M9 6l6 6-6 6"
                  stroke="currentColor"
                  strokeWidth={1.9}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </button>
          </div>
        )}
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →