Stepper
Numbered steps of one task: what is done, where you are, what is left, and the current step's content under them.
- Name, current stepOwner and name
- Visibility, not startedWho can see it
- Review, not started
- Build, completedpnpm build
- Test, current step
Running the unit and end-to-end tests
- Publish, not startednpm publish
- Press Next with the name empty: the step turns red until you type one.
- Finished steps become buttons: click one to go back to it.
- The vertical stepper shows the current step's content under its title, with a spinner while it is loading.
Field marks
- Import
import { Stepper, Step } from "cennge-ui";- Size
- 2.5 KB gzip for this import, its dependencies aside
- Pattern
- An ordered list,
aria-current="step"on the current step; withonStepClick, finished steps are buttons - Keyboard
- Tab Enter Space
- State
- Controlled:
activeStep, andonStepClickfor clicks on the steps - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Layout
- Labels go under the numbers when a horizontal Stepper is narrower than 28rem (a container query)
- Content
- Only the current step's children render, then
completedContent - Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: stepper
- Source
- src/components/stepper
When to use#
A Stepper splits one task into steps that come in order, creating a repository or publishing a release, and shows how far along it is. Each Step can hold the form for that step: only the current one renders, and once activeStep is past the last step, completedContent takes its place.
Similar#
- Pagination moves through pages of the same content in any order; a Stepper leads through different steps of one task.
- Timeline lists events in order, each with its time; a Stepper leads through a task and holds each step's content.
- Tabs show panels in any order, with none of them done or not done.
A wizard#
The Stepper holds no state: keep activeStep yourself and move it with your own Back and Next buttons. With onStepClick, finished steps become buttons that take the reader back; steps ahead stay out of reach. Keep the fields' values in state above the Stepper too, as the notes here: only the current step is rendered, so a field in a step you leave would start empty when you come back.
- Version, current steppatch, minor or major
- Notes, not startedFor the changelog
- Publish, not started
import { useState } from "react";
import { Button, Radio, RadioGroup, Step, Stepper, Textarea } from "cennge-ui";
export default function ReleaseWizard() {
const [step, setStep] = useState(0);
const [bump, setBump] = useState("patch");
const [notes, setNotes] = useState("");
function forward() {
if (step === 3) setNotes("");
setStep(step === 3 ? 0 : step + 1);
}
return (
<div className="flex w-full flex-col gap-5">
<Stepper
activeStep={step}
onStepClick={setStep}
aria-label="New payload-cache release"
completedContent={<p>payload-cache is on its way to npm with a {bump} release.</p>}
>
<Step title="Version" description="patch, minor or major">
<RadioGroup label="Version" value={bump} onValueChange={setBump} orientation="horizontal">
<Radio value="patch">Patch</Radio>
<Radio value="minor">Minor</Radio>
<Radio value="major">Major</Radio>
</RadioGroup>
</Step>
<Step title="Notes" description="For the changelog">
<Textarea label="What changed" placeholder="One line per change" value={notes} onValueChange={setNotes} className="max-w-96" />
</Step>
<Step title="Publish">
<p>A {bump} release of payload-cache goes to npm.</p>
</Step>
</Stepper>
<div className="flex gap-2">
{step < 3 ? (
<Button variant="flat" isDisabled={step === 0} onClick={() => setStep(step - 1)}>
Back
</Button>
) : null}
<Button color="primary" onClick={forward}>
{step === 3 ? "Start again" : step === 2 ? "Publish" : "Next"}
</Button>
</div>
</div>
);
}Steps in any order#
allowNextStepsSelect makes the steps ahead clickable too, for steps that do not depend on each other. Every step before the current one shows as completed, skipped ones included, so keep it for steps that are fine with their defaults. The current step is never a button, and a step with isDisabled never is.
- General, current step
Name, description and topics of ai-niche-scout.
import { useState } from "react";
import { Step, Stepper } from "cennge-ui";
const sections = [
{ title: "General", text: "Name, description and topics of ai-niche-scout." },
{ title: "Branches", text: "Protect main: changes arrive through pull requests." },
{ title: "Notifications", text: "Release notes by email to dev@example.com." },
];
export default function SetupSteps() {
const [step, setStep] = useState(0);
return (
<Stepper activeStep={step} onStepClick={setStep} allowNextStepsSelect aria-label="Set up ai-niche-scout" className="w-full">
{sections.map((section) => (
<Step key={section.title} title={section.title}>
<p className="text-muted">{section.text}</p>
</Step>
))}
</Stepper>
);
}Vertical, with errors and loading#
With orientation="vertical" the current step's content sits under its title, beside the line that joins the steps. isError turns a step red with an exclamation mark, and stays until you take it off; isLoading puts a spinner in the current step's circle. Press Run again.
- Install, completedDependencies
- Lint, completedCode style
- Test, has an errorUnit tests
A test of the spread alert failed.
- Deploy, not startedTo production
import { useState } from "react";
import { Button, Step, Stepper } from "cennge-ui";
export default function CheckRun() {
const [step, setStep] = useState(2);
const [isRunning, setRunning] = useState(false);
async function rerun() {
setRunning(true);
// Stands in for the real run.
await new Promise((resolve) => setTimeout(resolve, 2000));
setRunning(false);
setStep(3);
}
return (
<Stepper orientation="vertical" activeStep={step} aria-label="CI for spredo-crypto-screener" className="max-w-sm">
<Step title="Install" description="Dependencies" />
<Step title="Lint" description="Code style" />
<Step title="Test" description="Unit tests" isError={step === 2 && !isRunning} isLoading={isRunning}>
<div className="flex flex-col items-start gap-3">
<p className="text-muted">{isRunning ? "Running the tests again" : "A test of the spread alert failed."}</p>
<Button size="sm" variant="flat" isDisabled={isRunning} onClick={rerun}>
Run again
</Button>
</div>
</Step>
<Step title="Deploy" description="To production">
<p className="text-muted">Waiting for a reviewer to approve.</p>
</Step>
</Stepper>
);
}In a narrow column#
A horizontal Stepper looks at its own width, not the window's: narrower than 28rem, its labels move under the numbers and the lines between steps go. In a sidebar or a card it changes on a desktop screen too.
- Build, completed
- Preview, current step
- Production, not started
import { Step, Stepper } from "cennge-ui";
export default function NarrowStepper() {
return (
<div className="w-full max-w-xs rounded-large bg-surface-1 p-4">
<Stepper activeStep={1} size="sm" aria-label="Deploy payload-cache">
<Step title="Build" />
<Step title="Preview" />
<Step title="Production" />
</Stepper>
</div>
);
}Accessibility#
The steps are an ordered list named by aria-label, "Progress" unless you name it. After each title, screen readers hear its state: "completed", "current step", "not started" or "has an error"; translate them with stateLabels. The numbers, check marks and lines are hidden from them.
Props#
Stepper#
Also takes every attribute of <div>, except onChange, which mean something else here.