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.

anatomy
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.

  • StepperPrev and StepperNext render a bare <button> with no classes. Pass className={buttonVariants()} to keep their built-in labels, or asChild around your own Button — then the child supplies the text.
  • The trigger's focus ring is rounded-md when 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/…

Step 1 of 3

value = "workspace" · done = []

  • Who asks. StepperNext, a click on any trigger (in either direction — hence the early return true for 'prev') and the arrow keys going forward. StepperPrev and the arrow keys going back move without asking. A promise works: the move waits for it, and a rejection counts as false.
  • 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-describedby when 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.
  • StepCount is a part of the app's own, built on useStepper(selector), which reads { value, steps } from the nearest stepper (steps is a Map in step order). Since 3.1.0 it 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 empty steps, 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 a Stepper, 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.
  • nonInteractive keeps 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.

KeysWhat happens
TabEnters 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 · PageUpFirst step.
End · PageDownLast step.
Enter · SpaceOpens 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” from labels. Upstream rendered an empty button with no accessible name.
  • Toggling completed or disabled updates 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, and onValueRemove / onValueAdd fired 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-ink too; the rest stays a decorative 1px --border hairline.
  • The focus ring is the package's Checkbox / RadioGroup ring instead of upstream's 3px one, an aria-invalid trigger is outlined in --destructive-ink, and the separator is aria-hidden without a role="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 = “”

KeyEnglishTiếng ViệtWhere
completedStep(1)Step 1, completedBước 1, đã hoàn thànhsr-only text in a completed step's indicator — a function, the number sits inside the sentence
prevPreviousQuay lạiStepperPrev without children
nextNextTiếp theoStepperNext without children
  • Precedence, per key: labels on the Stepper → the provider → English. labels is Partial<StepperLabels>, so one key can change without losing the others.
  • DEFAULT_STEPPER_LABELS is the English table since 3.0.0, and the component no longer reads it — it is there to spread when you build labels of your own. The Vietnamese table is comitorMessagesVi.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.
labels
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.

PropTypeDefaultDescription
valuestring—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.
defaultValuestring—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.
loopbooleanfalseArrow keys wrap from the last step to the first and back.
disabledbooleanfalseDisables every trigger. StepperPrev and StepperNext do not read it — disable them yourself too.
nonInteractivebooleanfalseRead-only: triggers ignore clicks, Enter and keyboard focus. They stay focusable tabs, and the arrow keys still move between them.
dir'ltr' | 'rtl'from DirectionProviderReading direction. Under rtl, Left and Right swap.
labelsPartial<StepperLabels>from the providerPer-key override of the stepper namespace (prev, next, completedStep). Beats ComitorLocaleProvider; missing keys still come from it.
idstringuseId()Prefix for the ids that tie each trigger to its panel, title and description.
asChildbooleanfalseRender the root as its only child (Radix Slot) instead of a div.

StepperItem props

PropTypeDefaultDescription
valuerequiredstring—Identifies the step and goes into the ids of its trigger and panel. Its position is the order in which it registered.
completedbooleanfalseDraw 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.
disabledbooleanfalseThe trigger cannot be clicked or reached with the arrow keys. StepperNext and StepperPrev still move onto the step.
asChildbooleanfalseRender as the only child instead of a div.

The other parts

Each also takes the props of the element it renders and asChild.

PropTypeDefaultDescription
StepperTriggerbutton 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.
StepperIndicatorchildren?: 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.
StepperSeparatorforceMount?: booleanfalseSkipped 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 / StepperDescriptionspan props · asChild—Carry the ids the trigger's aria-describedby points at. Put them inside the trigger.
StepperContentvalue: 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 / StepperNextbutton 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.

steps written out
'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.

conditional steps
// 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 disabled step, and the root's disabled does not reach them. To skip a step, drive value from buttons of your own.
  • forceMount on StepperContent leaves inactive panels visible. The part sets no hidden attribute and no data-state, so hide them with your own value.

Accessibility

  • The list is role="tablist" with aria-orientation; each trigger is a <button> with role="tab", aria-selected, aria-posinset / aria-setsize, aria-controls pointing at its panel and aria-current="step" on the active one; each panel is role="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-describedby points 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 disabled attribute: out of the tab order and skipped by the arrow keys. The separator is aria-hidden.
  • Name the list. A tab list without aria-label is announced only as “tab list”. Every demo on this page passes one to StepperList.
  • Say why a move was refused. A failed onValidate announces 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.