Stepper
The segmented stage track: done stages in Ink 25, the current one in Ink 80.
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 Label-Beside-Color Rule
Anatomy#
- Upload (complete)
- Map (complete)
- Review (current stage)
- Import
- Track. An ordered list named by
label, one equal-width item per stage with 4px gaps. - Completed segment. A 4px round-ended bar in Ink 25. Its label is Slate Meta with a 12px check.
- Current segment. Ink 80. The list item carries
aria-current="step". - Current label. 12px, medium weight, ink. The only label shown below 640px.
- 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
- Connect bank (complete)
- Import suppliers (complete)
- Invite team (current stage)
- 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#
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> );}| State | Treatment |
|---|---|
| Completed | Ink 25 segment, check icon, Slate Meta label, and (complete) for screen readers. |
| Current | Ink 80 segment and a medium ink label, with (current stage) for screen readers. |
| Upcoming | Well Gray segment and a Slate Meta label. |
| Hover | Only when clickable: a completed or upcoming segment deepens to Ink 45. The current segment doesn't change. |
| Focus visible | Only when clickable: a 3px Focus Indigo ring at 40% around the segment and its label. |
| Changing | Segment colors cross-fade over 200ms when current moves. |
Behavior#
- Stages before
currentread as complete,currentas current, and the rest as upcoming. The component has no other states: no skipped, failed or lost stage. - If
currentisn't insteps, every stage reads as upcoming and none is marked current. Deal records draw Closed lost as a separate notice instead of the track. - Without
onStepSelecteach stage is a plain block. With it, each stage is a 32px-tall button that callsonStepSelect(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#
- Discovery (complete)
- Proposal (current stage)
- Closed won
- Discovery
- Proposal
- Closed won
- Goal (complete)
- Audience (current stage)
- Message
- Schedule
- Choose what the campaign is for (complete)
- Pick which suppliers to call (current stage)
- Write the message agents read
- Decide when calls go out
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.
| Keys | Action |
|---|---|
| Tab | Moves through the stages when they are clickable. |
| Enter | Selects the focused stage. |
| Space | Selects the focused stage. |
Design tokens#
| Token | Used for |
|---|---|
--foreground | Segments at 25% (Ink 25, done), 80% (Ink 80, current) and 45% (hover), written as bg-foreground/25 and so on; the current label |
--muted | Upcoming segments (Well Gray) |
--muted-foreground | Other labels and the check |
--ring | 3px 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..
| Prop | Type | Default | Description |
|---|---|---|---|
stepsRequired | Step[] | No default | The stages in order. |
currentRequired | string | No default | The id of the current stage. Earlier stages read as complete. |
labelRequired | string | No default | The accessible name of the track, such as Deal stage. |
onStepSelect | (id: string) => void | No default | Makes every stage a button and is called with its id on click. |
className | string | No default | Merged 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.