Web InnoventixFreeCode

Numbered Stepper

Original · free

numbered stepper with connectors

byWeb InnoventixReact + Tailwind
stepnumberedsteppers
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-numbered.json
step-numbered.tsx
"use client";

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

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

const STEPS: Step[] = [
  {
    id: "workspace",
    label: "Workspace",
    title: "Create your workspace",
    blurb:
      "Spin up an isolated environment with its own secrets, custom domains, and deploy history — kept fully separate from everyone else's.",
    points: [
      "Pick a region so builds run close to your users",
      "Choose a URL-safe workspace slug",
      "Set the default branch we watch for changes",
    ],
  },
  {
    id: "repo",
    label: "Repository",
    title: "Connect your repository",
    blurb:
      "Link the Git provider that holds your source. We read commits to trigger builds and never push back to your code.",
    points: [
      "GitHub, GitLab, or Bitbucket in two clicks",
      "A read-only deploy key is installed for you",
      "We auto-detect your build and start commands",
    ],
  },
  {
    id: "pipeline",
    label: "Pipeline",
    title: "Configure the pipeline",
    blurb:
      "Describe how raw code becomes a running service — the build, the tests, and the gates that guard production.",
    points: [
      "Define install, build, and start steps",
      "Run your test suite on every push",
      "Require green checks before a promote",
    ],
  },
  {
    id: "team",
    label: "Team",
    title: "Invite your team",
    blurb:
      "Add the people who ship with you and scope exactly what each role is allowed to touch.",
    points: [
      "Developers deploy to preview URLs",
      "Maintainers promote builds to production",
      "Billing and secrets stay owner-only",
    ],
  },
  {
    id: "deploy",
    label: "Deploy",
    title: "Ship it",
    blurb:
      "Push the button. We build an immutable artifact, run every check, and roll it out with zero downtime.",
    points: [
      "Health-checked, gradual rollout",
      "Automatic rollback if a check fails",
      "A shareable URL the moment it is live",
    ],
  },
];

type StepStatus = "complete" | "current" | "reachable" | "locked";

const STATUS_ANNOUNCE: Record<StepStatus, string> = {
  complete: "completed",
  current: "current step",
  reachable: "not started",
  locked: "locked",
};

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2.5}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M5 13l4 4L19 7" />
    </svg>
  );
}

export default function StepNumbered() {
  const prefersReduced = useReducedMotion();
  const panelId = useId();

  const N = STEPS.length;
  const [active, setActive] = useState(0);
  const [maxReached, setMaxReached] = useState(0);
  const [focusIndex, setFocusIndex] = useState(0);
  const [direction, setDirection] = useState(1);
  const [finished, setFinished] = useState(false);

  const stepRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const trackInset = 100 / (2 * N);
  const fillPct = finished ? 100 : (maxReached / (N - 1)) * 100;

  function statusFor(i: number): StepStatus {
    if (finished) return "complete";
    if (i === active) return "current";
    if (i < maxReached) return "complete";
    if (i <= maxReached) return "reachable";
    return "locked";
  }

  function goToStep(i: number) {
    if (i > maxReached) return;
    setDirection(i >= active ? 1 : -1);
    setFinished(false);
    setActive(i);
    setFocusIndex(i);
  }

  function goNext() {
    if (finished) return;
    if (active === N - 1) {
      setFinished(true);
      setMaxReached(N - 1);
      return;
    }
    const next = active + 1;
    setDirection(1);
    setActive(next);
    setMaxReached((m) => Math.max(m, next));
    setFocusIndex(next);
  }

  function goBack() {
    if (active === 0 || finished) return;
    const prev = active - 1;
    setDirection(-1);
    setActive(prev);
    setFocusIndex(prev);
  }

  function reset() {
    setFinished(false);
    setDirection(-1);
    setActive(0);
    setMaxReached(0);
    setFocusIndex(0);
  }

  function onListKeyDown(e: ReactKeyboardEvent<HTMLOListElement>) {
    let next = focusIndex;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        next = Math.min(focusIndex + 1, maxReached);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        next = Math.max(focusIndex - 1, 0);
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = maxReached;
        break;
      default:
        return;
    }
    e.preventDefault();
    setFocusIndex(next);
    stepRefs.current[next]?.focus();
  }

  const dur = prefersReduced ? 0 : 0.28;
  const panelVariants = {
    enter: (d: number) => ({ opacity: 0, x: prefersReduced ? 0 : d * 28 }),
    center: { opacity: 1, x: 0 },
    exit: (d: number) => ({ opacity: 0, x: prefersReduced ? 0 : d * -28 }),
  };

  const primaryLabel = finished
    ? "Start over"
    : active === N - 1
      ? "Finish setup"
      : "Continue";

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes stepnum-pop {
          0% { transform: scale(0.55); }
          60% { transform: scale(1.08); }
          100% { transform: scale(1); }
        }
        @keyframes stepnum-ring {
          0% { transform: scale(0.85); opacity: 0.55; }
          70% { transform: scale(1.65); opacity: 0; }
          100% { transform: scale(1.65); opacity: 0; }
        }
        @keyframes stepnum-draw {
          from { stroke-dashoffset: 1; }
          to { stroke-dashoffset: 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          .stepnum-animated { animation: none !important; }
        }
      `}</style>

      {/* Ambient backdrop */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[46rem] max-w-full -translate-x-1/2 rounded-full bg-gradient-to-r from-indigo-300/40 via-violet-300/30 to-sky-300/40 blur-3xl dark:from-indigo-700/20 dark:via-violet-700/20 dark:to-sky-700/20"
      />

      <div className="relative mx-auto max-w-3xl">
        <header className="mx-auto max-w-2xl text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Guided setup
          </span>
          <h2 className="mt-5 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Ship your first release
          </h2>
          <p className="mt-4 text-base text-slate-600 dark:text-slate-300">
            Five steps from an empty workspace to a health-checked production
            deploy.
          </p>
        </header>

        {/* Stepper */}
        <nav aria-label="Release setup progress" className="mt-12">
          <div className="relative">
            {/* Connector track */}
            <div
              aria-hidden="true"
              className="absolute top-5 h-1 -translate-y-1/2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
              style={{ left: `${trackInset}%`, right: `${trackInset}%` } as CSSProperties}
            >
              <motion.div
                className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-teal-400"
                initial={false}
                animate={{ width: `${fillPct}%` }}
                transition={{ duration: dur, ease: "easeOut" }}
              />
            </div>

            <ol className="relative flex items-start" onKeyDown={onListKeyDown}>
              {STEPS.map((step, i) => {
                const status = statusFor(i);
                const isComplete = status === "complete";
                const isCurrent = status === "current";
                const isLocked = status === "locked";

                const circleClasses =
                  isComplete
                    ? "bg-emerald-500 text-white shadow-sm shadow-emerald-500/30"
                    : isCurrent
                      ? "bg-gradient-to-br from-indigo-500 to-violet-500 text-white shadow-md shadow-indigo-500/40"
                      : isLocked
                        ? "border-2 border-slate-200 bg-white text-slate-400 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-500"
                        : "border-2 border-indigo-400 bg-white text-indigo-600 dark:bg-slate-900 dark:text-indigo-300";

                const labelClasses = isCurrent
                  ? "text-indigo-700 dark:text-indigo-300"
                  : isComplete
                    ? "text-slate-700 dark:text-slate-300"
                    : isLocked
                      ? "text-slate-400 dark:text-slate-500"
                      : "text-slate-600 dark:text-slate-400";

                return (
                  <li key={step.id} className="flex flex-1 justify-center">
                    <button
                      type="button"
                      ref={(el) => {
                        stepRefs.current[i] = el;
                      }}
                      onClick={() => goToStep(i)}
                      disabled={isLocked}
                      tabIndex={i === focusIndex ? 0 : -1}
                      aria-current={isCurrent ? "step" : undefined}
                      aria-controls={panelId}
                      aria-label={`Step ${i + 1}: ${step.label}, ${STATUS_ANNOUNCE[status]}`}
                      className="group flex flex-col items-center gap-2.5 rounded-2xl px-1.5 py-1.5 outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 disabled:cursor-not-allowed dark:focus-visible:ring-offset-slate-950"
                    >
                      <span className="relative flex items-center justify-center">
                        {isCurrent && !prefersReduced ? (
                          <span
                            aria-hidden="true"
                            className="stepnum-animated pointer-events-none absolute inset-0 rounded-full bg-indigo-400/50"
                            style={{ animation: "stepnum-ring 1.9s ease-out infinite" }}
                          />
                        ) : null}
                        <span
                          key={status}
                          className={`stepnum-animated relative z-10 flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold transition group-hover:brightness-110 ${circleClasses}`}
                          style={
                            prefersReduced
                              ? undefined
                              : { animation: "stepnum-pop 0.35s ease" }
                          }
                        >
                          {isComplete ? (
                            <svg
                              viewBox="0 0 24 24"
                              fill="none"
                              stroke="currentColor"
                              strokeWidth={3}
                              strokeLinecap="round"
                              strokeLinejoin="round"
                              className="h-5 w-5"
                              aria-hidden="true"
                            >
                              <path
                                d="M5 13l4 4L19 7"
                                pathLength={1}
                                className="stepnum-animated"
                                style={{
                                  strokeDasharray: 1,
                                  strokeDashoffset: 0,
                                  animation: prefersReduced
                                    ? undefined
                                    : "stepnum-draw 0.45s ease forwards",
                                }}
                              />
                            </svg>
                          ) : (
                            <span>{i + 1}</span>
                          )}
                        </span>
                      </span>
                      <span
                        className={`max-w-[6rem] text-center text-xs font-medium leading-tight sm:text-sm ${labelClasses}`}
                      >
                        {step.label}
                      </span>
                    </button>
                  </li>
                );
              })}
            </ol>
          </div>
        </nav>

        {/* Panel */}
        <div
          id={panelId}
          role="group"
          aria-live="polite"
          className="mt-10 overflow-hidden rounded-2xl border border-slate-200 bg-white/70 p-6 backdrop-blur sm:p-8 dark:border-slate-800 dark:bg-slate-900/60"
        >
          <AnimatePresence mode="wait" custom={direction} initial={false}>
            <motion.div
              key={finished ? "finished" : STEPS[active].id}
              custom={direction}
              variants={panelVariants}
              initial="enter"
              animate="center"
              exit="exit"
              transition={{ duration: dur, ease: "easeOut" }}
            >
              {finished ? (
                <div>
                  <span className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-emerald-500 text-white shadow-sm shadow-emerald-500/30">
                    <CheckIcon className="h-6 w-6" />
                  </span>
                  <h3 className="mt-4 text-xl font-semibold text-slate-900 sm:text-2xl dark:text-white">
                    You are live in production
                  </h3>
                  <p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                    Your first release passed every check and is serving traffic
                    now. From here, every push to your default branch ships the
                    exact same way — build, test, promote.
                  </p>
                  <dl className="mt-6 grid gap-3 sm:grid-cols-3">
                    {[
                      { k: "Build", v: "42s" },
                      { k: "Checks", v: "18 / 18 green" },
                      { k: "Rollout", v: "Zero downtime" },
                    ].map((stat) => (
                      <div
                        key={stat.k}
                        className="rounded-xl border border-slate-200/70 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/40"
                      >
                        <dt className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
                          {stat.k}
                        </dt>
                        <dd className="mt-1 text-sm font-semibold text-slate-900 dark:text-white">
                          {stat.v}
                        </dd>
                      </div>
                    ))}
                  </dl>
                </div>
              ) : (
                <div>
                  <p className="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
                    Step {active + 1} of {N}
                  </p>
                  <h3 className="mt-2 text-xl font-semibold text-slate-900 sm:text-2xl dark:text-white">
                    {STEPS[active].title}
                  </h3>
                  <p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                    {STEPS[active].blurb}
                  </p>
                  <ul className="mt-6 grid gap-3 sm:grid-cols-2">
                    {STEPS[active].points.map((point) => (
                      <li
                        key={point}
                        className="flex items-start gap-3 rounded-xl border border-slate-200/70 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/40"
                      >
                        <CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
                        <span className="text-sm text-slate-700 dark:text-slate-200">
                          {point}
                        </span>
                      </li>
                    ))}
                  </ul>
                </div>
              )}
            </motion.div>
          </AnimatePresence>
        </div>

        {/* Controls */}
        <div className="mt-6 flex flex-col-reverse gap-4 sm:flex-row sm:items-center sm:justify-between">
          <button
            type="button"
            onClick={goBack}
            disabled={active === 0 || finished}
            className="inline-flex items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-semibold text-slate-700 outline-none transition hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 disabled:cursor-not-allowed disabled:opacity-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"
              stroke="currentColor"
              strokeWidth={2}
              strokeLinecap="round"
              strokeLinejoin="round"
              className="h-4 w-4"
              aria-hidden="true"
            >
              <path d="M15 18l-6-6 6-6" />
            </svg>
            Back
          </button>

          <p
            aria-hidden="true"
            className="hidden text-sm font-medium text-slate-500 sm:block dark:text-slate-400"
          >
            {finished ? "Setup complete" : `Step ${active + 1} of ${N}`}
          </p>

          <button
            type="button"
            onClick={finished ? reset : goNext}
            className="inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-br from-indigo-500 to-violet-500 px-5 py-2.5 text-sm font-semibold text-white shadow-sm shadow-indigo-500/30 outline-none transition hover:from-indigo-600 hover:to-violet-600 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"
          >
            {primaryLabel}
            {finished ? (
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                className="h-4 w-4"
                aria-hidden="true"
              >
                <path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
                <path d="M3 3v5h5" />
              </svg>
            ) : (
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                className="h-4 w-4"
                aria-hidden="true"
              >
                <path d="M9 18l6-6-6-6" />
              </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 →