Composites
Stepper
A flow broken into steps with its progress drawn above it — setting up a workspace, onboarding a new hire, creating a deal. Underneath, the steps are tabs, so the whole list is one keyboard stop, and a validation hook decides whether the user may move forward.
Import and origin. From the main entry — import { Stepper, StepperList, … } from '@comitor/ui' — with no optional peer to install. The file is a client component ("use client" inside): a Server Component page can render it, but useStepper can only be called from a client component, and onValidate / onValueChange have to be passed from one. Ported in 2.1.0 from Dice UI (MIT, © 2024 Sadman Sakib) onto the package's own primitives and tokens — the fixes are listed below and in the changelog.
Anatomy
Eleven parts; the first four are the skeleton and the rest are optional. Each step is a string value you choose. Its position — the number in its indicator, and what Prev and Next treat as its neighbours — is the order in which its StepperItem registered, which for steps rendered up front is simply markup order.
import {
Stepper, StepperList, StepperItem, StepperTrigger, StepperIndicator,
StepperTitle, StepperDescription, StepperSeparator, StepperContent,
StepperPrev, StepperNext,
} from '@comitor/ui'
<Stepper defaultValue="workspace">
<StepperList aria-label="Workspace setup"> {/* role="tablist" — one tab stop */}
<StepperItem value="workspace"> {/* one per step: completed, disabled */}
<StepperTrigger> {/* role="tab" on a <button> */}
<StepperIndicator /> {/* 1, 2, 3… — a check once done */}
<StepperTitle>Workspace</StepperTitle>
<StepperDescription>Name and address</StepperDescription>
</StepperTrigger>
<StepperSeparator /> {/* line to the next step, none after the last */}
</StepperItem>
{/* …one StepperItem per step */}
</StepperList>
<StepperContent value="workspace">…</StepperContent> {/* role="tabpanel", mounted only while active */}
<StepperPrev /> {/* "Previous" when it has no children */}
<StepperNext /> {/* "Next" — asks onValidate first */}
</Stepper>Basic usage
defaultValue picks the starting step. Only the active StepperContent is mounted, steps before the active one are drawn as done, and StepperPrev / StepperNext disable themselves at the ends of the list. Click a step, use the buttons, or Tab into the list and use the arrow keys.
Account
Sign in with your work email — Comitor sends a six-digit code, no password to remember.
StepperPrevandStepperNextrender a bare<button>with no classes. PassclassName={buttonVariants()}to keep their built-in labels, orasChildaround your ownButton— then the child supplies the text.- The trigger's focus ring is
rounded-mdwhen it holds both a title and a description, and a full pill otherwise — shaped for indicator-only and title-only steps.
A form that checks each step
The stepper owns no fields — it asks onValidate(target, direction) before every move and stays put on false. Here the value is controlled, each step's own fields are checked on the way forward, a step stays ticked once it passed (completed), and later steps are disabled until the ones before them pass. Try Next with the fields empty, then fill them in: acme-studio is a valid address.
Name your workspace
Your workspace will live at comitor.app/…
value = "workspace" · done = []
- Who asks.
StepperNext, a click on any trigger (in either direction — hence the earlyreturn truefor'prev') and the arrow keys going forward.StepperPrevand the arrow keys going back move without asking. A promise works: the move waits for it, and a rejection counts asfalse. - Validate where the user is, not where they are going. The first argument is the target; the step being left is the current value — yours, when you control it. A click can jump several steps ahead, which is why this form also disables every step whose predecessors have not passed.
- A failed check is silent. The stepper simply does not move. The demo moves focus to the first field that needs fixing a frame later, so the error is already in its
aria-describedbywhen a screen reader reads it. - Focus after a move is yours. Prev and Next disable themselves at the ends — Next here is also swapped for “Create workspace” — and a focused button that disappears drops focus to the page. After a Prev / Next move the demo focuses the new panel's heading; after an arrow-key move it leaves focus in the list.
StepCountis a part of the app's own, built onuseStepper(selector), which reads{ value, steps }from the nearest stepper (stepsis aMapin step order). Since3.1.0it sees the steps written out in your JSX on the server and in the first render too — this count is in the server HTML — so an emptysteps, or a position of 0, no longer means “not hydrated yet”. Return a primitive or an existing reference from the selector — one that builds a new array on every call makes React re-render forever. Outside aStepper, or in a Server Component, it throws.
Vertical, with manual activation
orientation="vertical" puts the list beside the content and moves the arrow keys to ↑ and ↓. This onboarding checklist also sets activationMode="manual" — arrows only move focus and Enter or Space opens a step, which suits panels that load something — and loop, so ↓ on the last step comes back to the first.
Equipment
MacBook Pro 14-inch, a 27-inch monitor and a door badge. The courier needs a signature at the Hanoi office.
Manual mode holds only while there is no onValidate. With a validator set, the arrow keys go through it and open the step they land on in either mode.
Read-only progress
A status the user cannot change — an expense claim moving through approval — is a stepper with a fixed value and nonInteractive: clicks, Enter and keyboard focus no longer open steps. The indicator here takes a function child to draw a clock on the step in progress.
Expense claim EXP-2026-0187 · 4,860,000 VND
- A function (or any) child replaces the whole default indicator — the number, the check, and the sr-only “Step N, completed”. It receives the state but not the position, so the demo takes the number from its own list and says the state itself.
nonInteractivekeeps the tab semantics: the steps are still focusable tabs that a screen reader announces as such. For a history that is purely informational, a Timeline reads more honestly.
Keyboard
The list is a roving-tabindex tab list: one Tab stop, arrows inside. In the default automatic mode, a step opens as focus reaches it — after asking onValidate when the move is forward; if the answer is no, focus stays where it was.
| Keys | What happens |
|---|---|
| Tab | Enters the list on the current step — the list is a single tab stop — and leaves it again. Shift + Tab leaves backwards. |
| ← → (horizontal) · ↑ ↓ (vertical) | Previous or next trigger, skipping disabled steps; with loop, wraps at the ends. Left and Right swap under dir=rtl. Ignored with a modifier key held. |
| Home · PageUp | First step. |
| End · PageDown | Last step. |
| Enter · Space | Opens the focused step. In automatic mode the step focus lands on is already open; manual mode waits for this key. |
What the port guarantees
Dice UI's stepper looked right in a screenshot; most of what the port changed only shows up when you click, type or listen. Behaviour you can rely on:
- The server render has the steps. Upstream learned them only as they registered, after mount, so its server HTML showed every indicator at “0”, no separators, and Prev and Next disabled. The root now reads the steps from its children as it renders, until they have registered — the layouts it cannot read are under Known limitations — and a part inside a
<Suspense>boundary, which hydrates later, gets the state the server rendered instead of a hydration error. - Prev and Next work as soon as the steps are known. Upstream mutated its list of steps in place, so the parts that read it never saw the steps register — both buttons sat disabled until the first step change. Every add, remove and update now swaps in a new list.
- A bare
<StepperPrev />or<StepperNext />has a name — “Previous” / “Next” fromlabels. Upstream rendered an empty button with no accessible name. - Toggling
completedordisabledupdates the step in place. Upstream re-registered it, which appended it to the end: step 1 became “Step 3”, Prev and Next computed the wrong neighbours, andonValueRemove/onValueAddfired for nothing. The form above flips both props as you move through it. - A completed step keeps its name. Upstream's check mark replaced the number, so a trigger holding only an indicator became a nameless button. The default indicator now adds a screen-reader-only “Step N, completed”.
- State is carried by the border, not only the fill. The active and completed indicators keep their gold fill, and their border is drawn in
--primary-ink. On the high-contrast palette that step darkens to--gold-500, 6.51:1 on the page; on the default palette it deliberately points back at the fill gold (about 1.86:1 on white, a figure the package records and accepts to match this site’s approved look), so there a completed step also says so with its check mark and its screen-reader text. At rest the border is the control edge (--control-edge) rather than the surface token--muted. The reached part of the connector is--primary-inktoo; the rest stays a decorative 1px--borderhairline. - The focus ring is the package's Checkbox / RadioGroup ring instead of upstream's 3px one, an
aria-invalidtrigger is outlined in--destructive-ink, and the separator isaria-hiddenwithout arole="separator"that nothing could reach.
Language
The stepper has three strings of its own, in the stepper namespace, and follows the nearest ComitorLocaleProvider — English when there is none. Your step titles and descriptions are yours to translate. Switch the language below: the buttons change, and so does the text a screen reader hears for the completed first step, read back from the DOM underneath.
sr-only text of step 1 = “”
| Key | English | Tiếng Việt | Where |
|---|---|---|---|
completedStep(1) | Step 1, completed | Bước 1, đã hoàn thành | sr-only text in a completed step's indicator — a function, the number sits inside the sentence |
prev | Previous | Quay lại | StepperPrev without children |
next | Next | Tiếp theo | StepperNext without children |
- Precedence, per key:
labelson theStepper→ the provider → English.labelsisPartial<StepperLabels>, so one key can change without losing the others. DEFAULT_STEPPER_LABELSis the English table since3.0.0, and the component no longer reads it — it is there to spread when you build labels of your own. The Vietnamese table iscomitorMessagesVi.stepper.completedStep(position)is a function because the number sits inside the sentence and each language decides where. Functions cannot cross from a Server Component into a client one, so declare custom labels in a client component.- Choosing the language once for the whole app — and why a Comitor app takes it from the user's profile — is covered in Get started → Set the language.
import { comitorMessagesVi, DEFAULT_STEPPER_LABELS, Stepper, type StepperLabels } from '@comitor/ui'
DEFAULT_STEPPER_LABELS // the ENGLISH table since 3.0.0 — the component no longer reads it
comitorMessagesVi.stepper // the Vietnamese one, the table <ComitorLocaleProvider locale="vi"> hands out
// One stepper, a key or two. labels is Partial<StepperLabels>: prop → provider → English.
// completedStep is a function, and functions cannot cross from a Server Component —
// declare this in a client component (a module constant keeps the reference stable).
const LABELS: Partial<StepperLabels> = {
next: 'Continue',
completedStep: (position) => `Step ${position} — done`,
}
<Stepper labels={LABELS}>…</Stepper>
// Or for every stepper in the app, through the provider's messages:
<ComitorLocaleProvider locale="en" messages={{ stepper: { next: 'Continue' } }}>…</ComitorLocaleProvider>Stepper props
On top of every <div> prop.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | The active step. Pair it with onValueChange to own it; the stepper keeps its own copy and syncs from this prop in a layout effect. |
defaultValue | string | — | The step to start on when uncontrolled. Without it (or value) no step is active, no panel shows, and Next goes to the first step. |
onValueChange | (value: string) => void | — | Fires after a move has passed onValidate — from Next, Prev, a trigger or the keyboard. It also fires when you change the value prop yourself. |
onValidate | (value: string, direction: 'next' | 'prev') => boolean | Promise<boolean> | — | Asked before a move with the target step and the direction; return false (or throw) to stay put. Next, trigger clicks in either direction and forward arrow keys ask it — Prev and backward arrow keys do not. May be async. |
onValueComplete | (value: string, completed: boolean) => void | — | Fires when a StepperItem's completed prop changes. Steps drawn as completed because they come before the active one do not fire it. |
onValueAdd | (value: string) => void | — | Fires as a StepperItem registers — on mount, and again for React's simulated remount in development. |
onValueRemove | (value: string) => void | — | Fires as a StepperItem unregisters on unmount. |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Horizontal stacks the list above the content; vertical puts it beside the content and moves the arrow keys to Up and Down. |
activationMode | 'automatic' | 'manual' | 'automatic' | Automatic opens a step as keyboard focus reaches it; manual waits for Enter or Space. With onValidate set, arrow keys open steps in both modes. |
loop | boolean | false | Arrow keys wrap from the last step to the first and back. |
disabled | boolean | false | Disables every trigger. StepperPrev and StepperNext do not read it — disable them yourself too. |
nonInteractive | boolean | false | Read-only: triggers ignore clicks, Enter and keyboard focus. They stay focusable tabs, and the arrow keys still move between them. |
dir | 'ltr' | 'rtl' | from DirectionProvider | Reading direction. Under rtl, Left and Right swap. |
labels | Partial<StepperLabels> | from the provider | Per-key override of the stepper namespace (prev, next, completedStep). Beats ComitorLocaleProvider; missing keys still come from it. |
id | string | useId() | Prefix for the ids that tie each trigger to its panel, title and description. |
asChild | boolean | false | Render the root as its only child (Radix Slot) instead of a div. |
StepperItem props
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | Identifies the step and goes into the ids of its trigger and panel. Its position is the order in which it registered. |
completed | boolean | false | Draw the step as done whatever its position — for a step finished out of order, or one after the active step that was already completed. It beats active in the indicator. |
disabled | boolean | false | The trigger cannot be clicked or reached with the arrow keys. StepperNext and StepperPrev still move onto the step. |
asChild | boolean | false | Render as the only child instead of a div. |
The other parts
Each also takes the props of the element it renders and asChild.
| Prop | Type | Default | Description |
|---|---|---|---|
StepperTrigger | button props · asChild | — | A <button> with role tab. Its own disabled adds to the item's. Carries aria-selected, aria-current=step while active, aria-posinset and aria-setsize, and aria-controls pointing at the panel. |
StepperIndicator | children?: ReactNode | ((state) => ReactNode) | — | Default: the step number, or a check plus sr-only “Step N, completed” once done. Children replace all of it; a function receives 'inactive' | 'active' | 'completed' but not the position. |
StepperSeparator | forceMount?: boolean | false | Skipped after the last step unless forceMount. Horizontal: h-px flex-1. Vertical: h-10 w-px — place it yourself for a line under the indicator. aria-hidden. |
StepperTitle / StepperDescription | span props · asChild | — | Carry the ids the trigger's aria-describedby points at. Put them inside the trigger. |
StepperContent | value: string · forceMount?: boolean | — | Mounted only while its step is active. forceMount keeps it mounted — and visible: the part sets no hidden attribute or data-state, so hide inactive panels yourself. |
StepperPrev / StepperNext | button props · asChild | — | Unstyled buttons — pass buttonVariants(). Children default to labels.prev / labels.next (ignored with asChild). disabled adds to the built-in rule: first step for Prev, last for Next. onClick runs first; preventDefault() cancels the move. |
Known limitations
Steps the root cannot see wait for hydration
The root reads its steps from its children as it renders, so the server HTML — what shows before React hydrates — already has the numbers, aria-posinset / aria-setsize, the separators, and working Prev and Next buttons. It sees a StepperItem written in a StepperList written in the Stepper, through .map, fragments, conditions and plain HTML elements. Anything else inside the list or around it — a component of your own, a Suspense, a context provider — hides the steps, and so does writing the tree straight in a Server Component. Then the whole list waits: until hydration every indicator reads “0”, no separators are drawn, and Prev and Next are disabled — no error, just a wrong first frame. Write the steps out in a client component; what goes inside a StepperItem can still be your own. A client-only wrapper added before 3.1.0 to hide that frame can go.
'use client'
import { StepperIndicator, StepperItem, StepperList, StepperSeparator, StepperTitle, StepperTrigger } from '@comitor/ui'
// Numbered only at hydration. The root cannot see the StepperItem inside SetupStep,
// so until then every indicator reads "0", no separators are drawn, and Prev and
// Next are disabled.
function SetupStep({ step }: { step: Step }) {
return <StepperItem value={step.value}>…</StepperItem>
}
<StepperList aria-label="Workspace setup">
{STEPS.map((step) => <SetupStep key={step.value} step={step} />)}
</StepperList>
// Right from the server render: the StepperItem is written out in the list, and
// what goes inside it can still be a component of your own.
function SetupStepTrigger({ step }: { step: Step }) {
return (
<StepperTrigger>
<StepperIndicator />
<StepperTitle>{step.title}</StepperTitle>
</StepperTrigger>
)
}
<StepperList aria-label="Workspace setup">
{STEPS.map((step) => (
<StepperItem key={step.value} value={step.value}>
<SetupStepTrigger step={step} />
<StepperSeparator />
</StepperItem>
))}
</StepperList>Order is registration order
A step that mounts after the others — a “Billing” step that appears when the user picks a paid plan — is appended to the end, wherever it sits in the markup. Arrow keys follow the DOM, but its number and Prev / Next follow the order it registered in.
// A step that mounts LATER is appended to the end of the order, wherever it sits
// in the markup: its number, and what Prev / Next consider its neighbours, follow
// registration order. Remount the stepper when the list changes…
const steps = plan === 'paid' ? ['workspace', 'billing', 'team'] : ['workspace', 'team']
<Stepper key={steps.join()} value={step} onValueChange={setStep}>…</Stepper>
// …or keep the step mounted and switch it off instead of removing it:
<StepperItem value="billing" disabled={plan !== 'paid'}>…</StepperItem>- Prev and Next move by position. They step onto a
disabledstep, and the root'sdisableddoes not reach them. To skip a step, drivevaluefrom buttons of your own. forceMountonStepperContentleaves inactive panels visible. The part sets nohiddenattribute and nodata-state, so hide them with your ownvalue.
Accessibility
- The list is
role="tablist"witharia-orientation; each trigger is a<button>withrole="tab",aria-selected,aria-posinset/aria-setsize,aria-controlspointing at its panel andaria-current="step"on the active one; each panel isrole="tabpanel"labelled by its trigger. A screen reader announces each step as a tab, with its position (“2 of 3”) and whether it is selected. - A trigger's name is its content — indicator, title, description — and its
aria-describedbypoints at the title and description ids. - A completed step's default indicator adds screen-reader-only “Step N, completed”; a custom indicator has to say it itself.
- Disabled steps get the
disabledattribute: out of the tab order and skipped by the arrow keys. The separator isaria-hidden. - Name the list. A tab list without
aria-labelis announced only as “tab list”. Every demo on this page passes one toStepperList. - Say why a move was refused. A failed
onValidateannounces nothing — move focus to the first invalid field, or render the error in a live region. - Put focus somewhere after Prev / Next. They disable themselves on the first and last step, and a focused button that becomes disabled drops focus to the page. The form demo focuses the new panel's heading.
- The stepper creates no
<form>and labels no field — the panels are yours to fill with properly labelled controls.
Related
- HR & People — the employee record form; onboarding a new hire is its multi-step cousin.
- Web Application — the create dialog and settings page a setup flow like the one above leads into.
- Form Section — one long page of grouped fields, often better than steps when people need to see everything at once.
- Timeline — a record's history, when nothing is left to step through.
- Tabs — the same roles and keyboard, without a sequence.