stepper
Render-prop step sequencer with play/pause that auto-pauses off-screen and under reduced motion.
figureinteractiveanimation
Preview
step 1
First: the request arrives.
Installation
shadcn CLI
Terminal
npx shadcn@latest add https://mocoui.site/r/stepper.jsonManual
Copy each file from the Source section into its target path:
registry/stepper/stepper.tsx → components/moco/stepper.tsx
registry/stepper/stepper.css → components/moco/stepper.cssUsage
usage.tsx
import { Stepper } from "./stepper";
const STEPS = [
{ key: "one", caption: "First: the request arrives." },
{ key: "two", caption: "Second: it gets processed." },
{ key: "three", caption: "Third: the response goes out." },
];
export default function StepperDemo() {
return (
<Stepper
steps={STEPS}
ariaLabel="Request lifecycle stepper"
render={(i) => (
<div
style={{
display: "grid",
placeItems: "center",
height: 120,
background: "var(--moco-panel)",
borderRadius: 2,
fontFamily: "var(--moco-mono)",
color: "var(--moco-ink)",
}}
>
step {i + 1}
</div>
)}
/>
);
}
Source
components/moco/stepper.tsx
"use client";
import * as React from "react";
import "./stepper.css";
import { useInView, useReducedMotion } from "@/components/moco/hooks";
export interface StepperStep {
key: string;
caption: string;
}
export interface StepperProps {
steps: StepperStep[];
render: (i: number) => React.ReactNode;
/** Accessible name for the stepper group. */
ariaLabel: string;
}
/**
* Generic state-machine stepper. Auto-play never starts on its own — it needs
* a click — and it stops when the component scrolls out of view.
*/
export function Stepper({ steps, render, ariaLabel }: StepperProps) {
const [i, setI] = React.useState(0);
const [playing, setPlaying] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
const visible = useInView(ref, { once: false, rootMargin: "0px" });
const reduced = useReducedMotion();
React.useEffect(() => {
if (!playing || !visible || reduced) return;
const t = setInterval(() => setI((v) => (v + 1) % steps.length), 1600);
return () => clearInterval(t);
}, [playing, visible, reduced, steps.length]);
React.useEffect(() => {
if (!visible) setPlaying(false);
}, [visible]);
return (
<div
ref={ref}
tabIndex={0}
role="group"
aria-label={ariaLabel}
onKeyDown={(e) => {
if (e.key === "ArrowRight") {
setPlaying(false);
setI((v) => Math.min(steps.length - 1, v + 1));
}
if (e.key === "ArrowLeft") {
setPlaying(false);
setI((v) => Math.max(0, v - 1));
}
}}
>
<div key={i} className="moco-win-fade">
{render(i)}
</div>
<p className="moco-step-cap" aria-live="polite">
{steps[i].caption}
</p>
<div className="moco-step-bar">
<button
type="button"
className="moco-step-btn"
aria-label="Previous step"
disabled={i === 0}
onClick={() => {
setPlaying(false);
setI((v) => Math.max(0, v - 1));
}}
>
◀
</button>
<button
type="button"
className="moco-step-btn"
aria-label="Next step"
disabled={i === steps.length - 1}
onClick={() => {
setPlaying(false);
setI((v) => Math.min(steps.length - 1, v + 1));
}}
>
▶
</button>
<span>
{i + 1} / {steps.length}
</span>
{reduced ? null : (
<button
type="button"
className="moco-step-btn"
aria-label={playing ? "Pause" : "Play"}
aria-pressed={playing}
onClick={() => setPlaying((v) => !v)}
>
{playing ? "❙❙" : "▶"}
</button>
)}
</div>
</div>
);
}
components/moco/stepper.css
.moco-win-fade {
animation: moco-fadein 220ms var(--moco-ease);
}
@keyframes moco-fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.moco-step-bar {
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--moco-line);
font-size: 12px;
color: var(--moco-faint);
}
.moco-step-btn {
width: 28px;
height: 28px;
border: 1px solid var(--moco-line);
border-radius: 2px;
display: grid;
place-items: center;
color: var(--moco-muted);
background: var(--moco-bg);
transition: color 150ms var(--moco-ease);
}
.moco-step-btn:hover:not(:disabled) {
color: var(--moco-accent-dk);
border-color: var(--moco-accent-dk);
}
.moco-step-btn:disabled {
opacity: 0.35;
cursor: default;
}
.moco-step-cap {
color: var(--moco-muted);
font-size: 13px;
margin-top: 1rem;
min-height: 2.6em;
}