Numbered Stepper
Original · freenumbered stepper with connectors
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"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
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 quoteSimilar components
Browse all →
Horizontal Stepper
Originalhorizontal step indicator

Vertical Stepper
Originalvertical stepper
Icons Stepper
Originalicon stepper

Progress Stepper
Originalstepper with progress fill

Dots Stepper
Originaldot stepper

Wizard Stepper
Originalwizard stepper with next/back

Checkout Stepper
Originalcheckout progress steps

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

