Skip to content
cennge-ui
GitHubv0.3.1, changelog

Stepper

Numbered steps of one task: what is done, where you are, what is left, and the current step's content under them.

  1. Name, current stepOwner and name
  2. Visibility, not startedWho can see it
  3. Review, not started
  1. Build, completedpnpm build
  2. Test, current step

    Running the unit and end-to-end tests

  3. Publish, not startednpm publish
  1. Press Next with the name empty: the step turns red until you type one.
  2. Finished steps become buttons: click one to go back to it.
  3. The vertical stepper shows the current step's content under its title, with a spinner while it is loading.
Live: the component itself, not a picture of it.

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; with onStepClick, finished steps are buttons
Keyboard
Tab Enter Space
State
Controlled: activeStep, and onStepClick for 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

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.

  1. Version, current steppatch, minor or major
  2. Notes, not startedFor the changelog
  3. Publish, not started
Version
wizard.tsx
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.

  1. General, current step

Name, description and topics of ai-niche-scout.

jump-ahead.tsx
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.

  1. Install, completedDependencies
  2. Lint, completedCode style
  3. Test, has an errorUnit tests

    A test of the spread alert failed.

  4. Deploy, not startedTo production
vertical.tsx
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.

  1. Build, completed
  2. Preview, current step
  3. Production, not started
narrow.tsx
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#

PropTypeDefault
activeSteprequired
numberIndex of the current step, from 0. Equal to the number of steps once everything is done.
โ€“
onStepClick
(index: number) => voidMakes finished steps clickable so the user can go back.
โ€“
allowNextStepsSelect
booleanWith onStepClick: also allow jumping to steps ahead.
false
orientation
"horizontal""vertical"
"horizontal"
color
"default""primary""secondary""success""warning""danger""info"
"primary"
size
"sm""md""lg"
"md"
completedContent
React.ReactNodeShown once activeStep is past the last step.
โ€“
stateLabels
Partial<Record<StepState, string>>Read by screen readers after each step title.
โ€“
aria-label
stringDefines a string value that labels the current element.
"Progress"
children
React.ReactNode<Step> elements.
โ€“

Also takes every attribute of <div>, except onChange, which mean something else here.

Step#

PropTypeDefault
titlerequired
React.ReactNode
โ€“
description
React.ReactNode
โ€“
icon
React.ReactNodeReplaces the step number.
โ€“
children
React.ReactNodeShown when the step is current: the form for this step.
โ€“
isError
booleanMarks the step as failed (e.g. validation).
โ€“
isLoading
booleanShows a spinner while the step is current, e.g. while saving.
โ€“
isDisabled
booleanNot clickable even when onStepClick is set.
โ€“