Skip to content

Stepper

  • 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:

ComponentUse it forFor example
ProgressA task whose completion is known, moving toward doneUpload at 40%, step 2 of 5
StepperThe named steps of a multi-step flow, and which one you’re onCheckout: Cart, Shipping, Payment
MeterA measurement within a known range, not a taskDisk usage, password strength, a score
SpinnerWork of unknown length, in a small spaceA button saying “Saving…”, a panel refreshing
SkeletonContent still loading whose shape is known, so the layout does not jumpPlaceholder rows in a list, a profile card
  1. Cart
  2. Shipping
  3. Payment
  4. 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>
);
}
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>;
PartRendersDoes
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.

statusLooksRead out
completea filled circle with a tickthe label, then “completed”
currentan outlined circle, bold labelthe label, and “current step”
upcoming (default)a muted outlined circlethe label

A screen reader also hears each step’s position, such as “2 of 4”, from the list.

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.

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 areStatuses
On step 1step 1 current, the rest upcoming
On step 3steps 1–2 complete, 3 current, 4 upcoming
On the last stepall but the last complete, the last current
Finishedevery 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.

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.

  1. Create an account (completed)
  2. Invite your teamOptional — you can do this later.
  3. Connect a repository
  4. 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.

  1. Details (completed)
  2. Plan
  3. 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>
  • 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 props
PropTypeDefaultDescription
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.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
Stepper.Item props
PropTypeDefaultDescription
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.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything 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-size or --stepper-complete-bg.
  • Translate the hidden text with labels={{ complete: '…' }}.