Documentation menu

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.

1 / 3

Installation

Ask Claude (MCP)

With the MCP server configured, just ask:

Claude
add the moco stepper component

shadcn CLI

Terminal
npx shadcn@latest add https://mocoui.site/r/stepper.json

Manual

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.css

Needs moco items: tokens, hooks

Usage

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;
}