Stepper
When to use
Section titled “When to use”- A checkout, sign-up or setup that takes several screens, in order
- Showing how many steps are left, and letting people go back to one they’ve done
Not for a single task’s percentage — use Progress — or for views people switch between freely, which are Tabs.
Choosing between showing progress, loading or a measurement:
| Component | Use it for | For example |
|---|---|---|
| Progress | A task whose completion is known, moving toward done | Upload at 40%, step 2 of 5 |
| Stepper | The named steps of a multi-step flow, and which one you’re on | Checkout: Cart, Shipping, Payment |
| Meter | A measurement within a known range, not a task | Disk usage, password strength, a score |
| Spinner | Work of unknown length, in a small space | A button saying “Saving…”, a panel refreshing |
| Skeleton | Content still loading whose shape is known, so the layout does not jump | Placeholder rows in a list, a profile card |
- Cart
- Shipping
- Payment
- Review
Cart
2 items: a notebook and a pen. Subtotal $18.00.
import { useEffect, useRef, useState, type ReactNode } from 'react';import { Button, Field, Heading, Link, Paragraph, RadioGroup, Stack, Stepper, type StepperStatus,} from '@arun-dev/ui';
const row = { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' };
/** Each step's title, and what it shows. The content is yours: the Stepper only shows progress. */const steps: { title: string; content: ReactNode }[] = [ { title: 'Cart', content: <Paragraph>2 items: a notebook and a pen. Subtotal $18.00.</Paragraph>, }, { title: 'Shipping', content: ( <Field.Root> <Field.Label>Delivery address</Field.Label> <Field.Control defaultValue="12 Analytical Way, London" /> </Field.Root> ), }, { title: 'Payment', content: ( <RadioGroup.Root defaultValue="card" aria-label="Payment method" style={{ display: 'grid', gap: 'var(--space-2xs)' }} > <label style={row}> <RadioGroup.Item value="card" /> Card ending 4242 </label> <label style={row}> <RadioGroup.Item value="invoice" /> Pay by invoice </label> </RadioGroup.Root> ), }, { title: 'Review', content: <Paragraph>2 items to 12 Analytical Way, paid by card. Total $22.50.</Paragraph>, },];
export default function StepperBasics() { // The current step is yours: here a number in state, often the URL. steps.length means // the order is placed — every step complete, none current. const [current, setCurrent] = useState(0); const done = current === steps.length; const status = (index: number): StepperStatus => index < current ? 'complete' : index === current ? 'current' : 'upcoming';
// Moving to another step moves focus to its heading, so a screen reader announces it. const heading = useRef<HTMLHeadingElement>(null); const moved = useRef(false); useEffect(() => { if (moved.current) heading.current?.focus(); }, [current]); const go = (step: number) => { moved.current = true; setCurrent(step); };
return ( <Stack gap="lg" style={{ inlineSize: '100%' }}> <Stepper.Root aria-label="Checkout progress"> {steps.map(({ title }, index) => ( <Stepper.Item key={title} status={status(index)}> {/* A complete step links back to it — usually its own URL — until the order is placed. */} {index < current && !done ? ( <Link href={`#${title.toLowerCase()}`} onClick={(event) => { event.preventDefault(); go(index); }} > {title} </Link> ) : ( title )} </Stepper.Item> ))} </Stepper.Root>
{/* The current step's content, below the Stepper. */} <Stack gap="sm"> <Heading level={3} size="lg" ref={heading} tabIndex={-1}> {done ? 'Order placed' : steps[current]?.title} </Heading> {done ? ( <Paragraph>Thank you. A receipt is on its way.</Paragraph> ) : ( steps[current]?.content )} </Stack>
<Stack direction="row" gap="2xs" justify="between"> {done ? ( <Button onClick={() => go(0)}>Start over</Button> ) : ( <> <Button disabled={current === 0} onClick={() => go(current - 1)}> Back </Button> {/* The last step completes itself: placing the order ticks it too. */} <Button variant="primary" onClick={() => go(current + 1)}> {current === steps.length - 1 ? 'Place order' : 'Next'} </Button> </> )} </Stack> </Stack> );}Anatomy
Section titled “Anatomy”import { Stepper } from '@arun-dev/ui';
<Stepper.Root aria-label="Checkout progress"> <Stepper.Item status="complete">Cart</Stepper.Item> <Stepper.Item status="current">Shipping</Stepper.Item> <Stepper.Item>Payment</Stepper.Item></Stepper.Root>;| Part | Renders | Does |
|---|---|---|
Root | <ol role="list"> | the list of steps; name it with aria-label |
Item | <li> | one step: a drawn number or tick, then your label |
The current step is yours. Keep it in state or the URL and give each Item its status,
as the page is yours for Pagination. The Stepper holds nothing.
status | Looks | Read out |
|---|---|---|
complete | a filled circle with a tick | the label, then “completed” |
current | an outlined circle, bold label | the label, and “current step” |
upcoming (default) | a muted outlined circle | the label |
A screen reader also hears each step’s position, such as “2 of 4”, from the list.
Steps and their content
Section titled “Steps and their content”The Stepper shows progress; the step’s content is yours. Render the current step’s form or summary next to it, from the same state that sets the statuses, as the example above does. There’s no content part, because which content shows is your routing: often each step is its own page.
When the step changes without a page load, move focus to the new step’s heading (give it
tabIndex={-1}), so a screen reader announces where you are.
Finishing the flow
Section titled “Finishing the flow”The last step completes the same way as the others: when you leave it. On the last step, the
button that moves on is the one that finishes, such as “Place order”. Pressing it marks every
step complete, so no step is current, and shows a confirmation in place of the step content.
| Where you are | Statuses |
|---|---|
| On step 1 | step 1 current, the rest upcoming |
| On step 3 | steps 1–2 complete, 3 current, 4 upcoming |
| On the last step | all but the last complete, the last current |
| Finished | every step complete |
On the last step it’s still current, not complete: you’re reviewing it, and nothing is
done until you confirm. Try it in the example, or pick Finished in the playground below.
Going back
Section titled “Going back”A step’s label is your content. Make a complete step a Link to its URL, or a button, so people can go back to it. Leave upcoming steps as text if they can’t be skipped to.
Vertical
Section titled “Vertical”- Create an account (completed)
- Invite your teamOptional — you can do this later.
- Connect a repository
- Deploy
import { Stack, Stepper, Text } from '@arun-dev/ui';
export default function StepperVertical() { return ( <Stepper.Root aria-label="Workspace setup" orientation="vertical"> <Stepper.Item status="complete">Create an account</Stepper.Item> <Stepper.Item status="current"> {/* A label can hold more than a title: here a line of detail under it. */} <Stack gap="3xs"> <span>Invite your team</span> <Text size="xs" color="muted" weight="normal"> Optional — you can do this later. </Text> </Stack> </Stepper.Item> <Stepper.Item>Connect a repository</Stepper.Item> <Stepper.Item>Deploy</Stepper.Item> </Stepper.Root> );}orientation="vertical" puts each label beside its number, with the line running down. It
suits a side panel, or many steps on a phone, where a row would leave too little room for each
label.
Playground
Section titled “Playground”- Details (completed)
- Plan
- Payment
Plan: Free, Pro or Team.
<Stepper.Root aria-label="Sign-up progress">
<Stepper.Item status="complete">Details</Stepper.Item>
<Stepper.Item status="current">Plan</Stepper.Item>
<Stepper.Item>Payment</Stepper.Item>
</Stepper.Root>Accessibility
Section titled “Accessibility”- Name the list with
aria-label, such as “Checkout progress”. - The number and tick are drawn and hidden from screen readers. The list position and the hidden “completed” text say the same in words, so the state never depends on the tick alone.
- At most one step is
current, and none once the flow is finished.
Stepper.Root
Section titled “Stepper.Root”| Prop | Type | Default | Description |
|---|---|---|---|
orientation | 'horizontal' | 'vertical' | 'horizontal' | Steps in a row, or down a column with each label beside its number. |
labels | { complete?: string } | { complete: 'completed' } | Text read out but not shown, for translating: complete follows a complete step's label. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Stepper.Item
Section titled “Stepper.Item”| Prop | Type | Default | Description |
|---|---|---|---|
status | 'complete' | 'current' | 'upcoming' | 'upcoming' | Done, in progress, or ahead. current sets aria-current="step"; complete draws a tick and adds the hidden completed label. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
- In a row, each step takes an equal share of the width and its label wraps. Past four or five
steps on a phone, use
orientation="vertical". - Restyle with the
--stepper-*tokens, such as--stepper-indicator-sizeor--stepper-complete-bg. - Translate the hidden text with
labels={{ complete: '…' }}.