Skip to content

Stepper

The segmented stage track: done stages in Ink 25, the current one in Ink 80.

Status
Beta
Category
Navigation
Adoption
Not used yet
import { Stepper } from "@oration/canon/components/stepper";
packages/canon/src/components/stepper.tsx
import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { Stepper } from "@oration/canon/components/stepper";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { ArrowRightIcon } from "lucide-react";import * as React from "react";export function Hero() {    const stages = [        { id: "discovery", label: "Discovery", exit: "Confirm invoice volume" },        {            id: "qualified",            label: "Qualified",            exit: "Book a demo with finance",        },        {            id: "proposal",            label: "Proposal",            exit: "Send pricing to Maya Okafor",        },        {            id: "negotiation",            label: "Negotiation",            exit: "Agree the security review",        },        { id: "won", label: "Closed won", exit: "" },    ];    const [stage, setStage] = React.useState("proposal");    const [exitDone, setExitDone] = React.useState(false);    const index = stages.findIndex((item) => item.id === stage);    const current = stages[index];    const next = stages[index + 1];    const move = (id: string) => {        const label = stages.find((item) => item.id === id)?.label;        setStage(id);        setExitDone(false);        toast.add({            type: "success",            title: `Moved to ${label}`,            description: "Northwind Freight, AP automation rollout.",        });    };    return (        <div className="flex w-full max-w-2xl flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border">            <Stepper                steps={stages}                current={stage}                label="Deal stage"                onStepSelect={move}            />            {next && current ? (                <div className="flex flex-wrap items-center justify-between gap-3 rounded-[10px] bg-muted/70 px-3.5 py-2.5">                    <label className="flex min-w-0 items-center gap-2.5 text-13">                        <Checkbox                            checked={exitDone}                            onCheckedChange={(checked) =>                                setExitDone(Boolean(checked))                            }                        />                        <span className="min-w-0">                            <span className="text-muted-foreground">                                To leave {current.label}:{" "}                            </span>                            <span                                className={cn(                                    "font-medium",                                    exitDone &&                                        "text-muted-foreground line-through",                                )}                            >                                {current.exit}                            </span>                        </span>                    </label>                    <Button                        type="button"                        variant={exitDone ? "default" : "outline"}                        size="sm"                        onClick={() => move(next.id)}                    >                        Move to {next.label}                        <ArrowRightIcon                            data-icon="inline-end"                            aria-hidden="true"                        />                    </Button>                </div>            ) : (                <p className="rounded-[10px] bg-muted/70 px-3.5 py-2.5 text-13">                    <span className="font-medium">Closed won on Sep 28.</span>{" "}                    <span className="text-muted-foreground">                        Kickoff with Tomás Ferreira on Friday, Oct 2.                    </span>                </p>            )}        </div>    );}

Usage#

Stepper is the stage track: one 4px segment per stage with 4px gaps, so you can see how far a deal, an import or a setup has come. Completed stages are Ink 25 with a check, the current one is Ink 80 with a medium label, and upcoming stages are Well Gray. It is drawn in ink, not indigo, because position is a magnitude, not a decision. When the stage can be changed, pass onStepSelect and every segment becomes a button. The mistake is making it clickable when moving isn't allowed: a segment that looks pressable and does nothing is worse than a static track.

When to use

  • For a deal's pipeline stage on its record page, with segments that move the deal when clicked.
  • For the steps of an import, a campaign setup or onboarding, so people see where they are and how much is left.
  • For three to seven ordered stages with short labels.

When not to use

  • For a percentage of a known task, such as an upload. Use Progress
  • For switching between views that have no order. Use Tabs
  • For a list of tasks an agent is working through, each with its own state. Use Task list
  • For a multi-step dialog whose pages stack like cards. Use Stacked dialog
  • To show a stage value in a table cell. Stages in rows are tags. Use Tag

The Ink Fill Rule

The stage track is a magnitude, drawn in ink steps on a Well Gray track: Ink 25 for done, Ink 80 for current. It is never indigo, and it turns semantic only when the value is a verdict, which a stage never is.

The Label-Beside-Color Rule

Segment shade alone doesn't say which stage is current. The current label is always visible, in ink at medium weight, and completed labels carry a check.

Anatomy#

  1. Upload (complete)
  2. Map (complete)
  3. Review (current stage)
  4. Import
  1. Track. An ordered list named by label, one equal-width item per stage with 4px gaps.
  2. Completed segment. A 4px round-ended bar in Ink 25. Its label is Slate Meta with a 12px check.
  3. Current segment. Ink 80. The list item carries aria-current="step".
  4. Current label. 12px, medium weight, ink. The only label shown below 640px.
  5. Upcoming segment. Well Gray with a Slate Meta label.

Examples#

Read-only

Without onStepSelect the track only reports position, as in the setup progress block. Nothing hovers or takes focus.

Set up Cedarline

  1. Connect bank (complete)
  2. Import suppliers (complete)
  3. Invite team (current stage)
  4. First payment run
import { Stepper } from "@oration/canon/components/stepper";export function ReadOnly() {    return (        <div className="w-full max-w-lg rounded-xl bg-card p-4 shadow-border">            <p className="mb-2 text-13 font-medium">Set up Cedarline</p>            <Stepper                label="Setup progress"                current="invite"                steps={[                    { id: "bank", label: "Connect bank" },                    { id: "suppliers", label: "Import suppliers" },                    { id: "invite", label: "Invite team" },                    { id: "run", label: "First payment run" },                ]}            />        </div>    );}

Clickable stages

With onStepSelect every stage is a button. The hero above is the deal record's stage panel: click a stage to move the deal, or check off the exit criterion and use Move to.

import { Stepper } from "@oration/canon/components/stepper";import * as React from "react";export function ManyStages() {    const [stage, setStage] = React.useState("approval");    return (        <Stepper            label="Invoice stage"            current={stage}            onStepSelect={setStage}            className="max-w-2xl"            steps={[                { id: "received", label: "Received" },                { id: "coded", label: "Coded" },                { id: "matched", label: "Matched" },                { id: "approval", label: "Approval" },                { id: "scheduled", label: "Scheduled" },                { id: "paid", label: "Paid" },                { id: "reconciled", label: "Reconciled" },            ]}        />    );}

Going back only

In a flow, earlier steps can be revisited but later ones must be earned. The handler ignores forward stages, and Back and Continue carry the flow.

suppliers-sep-2026.csv, 212 rows

import { Button } from "@oration/canon/components/button";import { Stepper } from "@oration/canon/components/stepper";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function BackOnly() {    const steps = [        { id: "upload", label: "Upload" },        { id: "map", label: "Map columns" },        { id: "review", label: "Review" },        { id: "import", label: "Import" },    ];    const [step, setStep] = React.useState("map");    const index = steps.findIndex((item) => item.id === step);    return (        <div className="flex w-full max-w-lg flex-col overflow-hidden rounded-xl bg-card shadow-border">            <div className="border-b border-border px-4 pt-3 pb-2">                <Stepper                    label="Import progress"                    steps={steps}                    current={step}                    onStepSelect={(id) => {                        if (steps.findIndex((item) => item.id === id) < index)                            setStep(id);                    }}                />            </div>            <div className="flex items-center justify-between gap-3 px-4 py-3">                <p className="text-13 text-muted-foreground">                    suppliers-sep-2026.csv, 212 rows                </p>                <div className="flex gap-2">                    <Button                        type="button"                        variant="ghost"                        size="sm"                        disabled={index === 0}                        onClick={() => setStep(steps[index - 1]?.id ?? step)}                    >                        Back                    </Button>                    <Button                        type="button"                        size="sm"                        onClick={() => {                            const next = steps[index + 1];                            if (next) setStep(next.id);                            else                                toast.add({                                    type: "success",                                    title: "212 suppliers imported",                                    description: "3 duplicates were merged.",                                });                        }}                    >                        {index === steps.length - 1 ? "Import" : "Continue"}                    </Button>                </div>            </div>        </div>    );}

States#

CompletedProposal
CurrentProposal
UpcomingProposal
HoverProposal
FocusProposal
import { cn } from "@oration/canon/lib/utils";import { CheckIcon } from "lucide-react";export function StatesMatrix() {    const states = [        { label: "Completed", bar: "bg-foreground/25", done: true },        { label: "Current", bar: "bg-foreground/80", done: false },        { label: "Upcoming", bar: "bg-muted", done: false },        { label: "Hover", bar: "bg-foreground/45", done: false },        { label: "Focus", bar: "bg-muted", done: false },    ];    return (        <div className="grid w-full grid-cols-5 gap-3" inert>            {states.map((state) => (                <div key={state.label} className="flex min-w-0 flex-col gap-2">                    <span className="text-xs text-muted-foreground">                        {state.label}                    </span>                    <span                        className={cn(                            "flex min-h-8 flex-col items-start gap-1.5 rounded-md py-1",                            state.label === "Focus" && "ring-3 ring-ring/40",                        )}                    >                        <span                            className={cn("h-1 w-full rounded-full", state.bar)}                        />                        <span                            className={cn(                                "flex items-center gap-1 text-xs",                                state.label === "Current"                                    ? "font-medium text-foreground"                                    : "text-muted-foreground",                            )}                        >                            {state.done ? (                                <CheckIcon                                    aria-hidden="true"                                    className="size-3"                                />                            ) : null}                            Proposal                        </span>                    </span>                </div>            ))}        </div>    );}
States
StateTreatment
CompletedInk 25 segment, check icon, Slate Meta label, and (complete) for screen readers.
CurrentInk 80 segment and a medium ink label, with (current stage) for screen readers.
UpcomingWell Gray segment and a Slate Meta label.
HoverOnly when clickable: a completed or upcoming segment deepens to Ink 45. The current segment doesn't change.
Focus visibleOnly when clickable: a 3px Focus Indigo ring at 40% around the segment and its label.
ChangingSegment colors cross-fade over 200ms when current moves.

Behavior#

  • Stages before current read as complete, current as current, and the rest as upcoming. The component has no other states: no skipped, failed or lost stage.
  • If current isn't in steps, every stage reads as upcoming and none is marked current. Deal records draw Closed lost as a separate notice instead of the track.
  • Without onStepSelect each stage is a plain block. With it, each stage is a 32px-tall button that calls onStepSelect(id), including the current one.
  • The handler can't disable individual stages. To allow only going back, as the import flow does, ignore forward ids in the handler, and consider leaving the track static so forward stages don't look pressable.
  • Labels truncate to their segment's width. Below 640px only the current label shows, the last stage's label aligns right, and the others stay available to screen readers.
  • Pair a clickable track with an explicit next action, as deal records do: the exit criterion to check off and a Move to Negotiation button beside it.

Do and don't#

  1. Discovery (complete)
  2. Proposal (current stage)
  3. Closed won
Do. Draw stages in ink steps and let the current label carry the meaning.
  1. Discovery
  2. Proposal
  3. Closed won
Don't. Fill the track in indigo or in stage hues. Position is a magnitude, and the indigo belongs to the view's one action.
  1. Goal (complete)
  2. Audience (current stage)
  3. Message
  4. Schedule
Do. Keep stage labels to one or two words so each fits its segment.
  1. Choose what the campaign is for (complete)
  2. Pick which suppliers to call (current stage)
  3. Write the message agents read
  4. Decide when calls go out
Don't. Write sentences as labels. They truncate at every width and the track stops being scannable.

Content#

  • Stage names are nouns in sentence case and match the pipeline's own stage options: Discovery, Proposal, Closed won.
  • Flow steps start with a verb: Upload, Map columns, Review, Import.
  • Name the track for what it measures: Deal stage, Import progress, Setup progress.

Accessibility#

  • The track is an ordered list named by label, so screen readers announce it as, for example, Deal stage, list, 5 items.
  • Each stage adds (complete) or (current stage) as screen reader text, and the current list item has aria-current="step".
  • Segments are aria-hidden; only the labels are read. Hidden labels below 640px stay in the accessibility tree.
  • Clickable stages are native buttons at least 32px tall. They don't announce that clicking changes the stage, so say so nearby, such as in a Move to button.
  • The only motion is a 200ms color change.
Keyboard interactions
KeysAction
TabMoves through the stages when they are clickable.
EnterSelects the focused stage.
SpaceSelects the focused stage.

Design tokens#

Design tokens
TokenUsed for
--foregroundSegments at 25% (Ink 25, done), 80% (Ink 80, current) and 45% (hover), written as bg-foreground/25 and so on; the current label
--mutedUpcoming segments (Well Gray)
--muted-foregroundOther labels and the check
--ring3px focus ring at 40%

API reference#

Stepper

The stage track. Also exported: the Step type, { id: string; label: string }.

Other props spread onto Nothing. Only the props below are accepted..

Props of Stepper
PropTypeDefaultDescription
stepsRequiredStep[]No defaultThe stages in order.
currentRequiredstringNo defaultThe id of the current stage. Earlier stages read as complete.
labelRequiredstringNo defaultThe accessible name of the track, such as Deal stage.
onStepSelect(id: string) => voidNo defaultMakes every stage a button and is called with its id on click.
classNamestringNo defaultMerged onto the list.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

onStepSelect is all or nothing. The import flow passes a handler that ignores forward stages, so Review and Import hover and focus like buttons but do nothing when clicked.

The screen reader suffix is always (current stage), which reads oddly on flow steps such as Map columns (current stage). There is no prop to change it.

There are no skipped, failed or lost stages. A deal that closes lost can't be drawn on the track at all.

aria-current="step" sits on the list item, not on the button, so it isn't announced when a clickable stage takes focus. The screen reader suffix covers it.