Primitives
Type-safe primitive components for building stepper UIs
The Stepper object returned by defineStepper provides a set of primitive components that are type-safe and bound to your step definitions. They handle ARIA attributes, keyboard navigation, and data attributes automatically; stepper state is provided by Scoped or Stepper.Root.
Overview
import * as React from "react"
import { } from "@stepperize/react"
const { } = (
{ : "shipping", : "Shipping" },
{ : "payment", : "Payment" },
{ : "confirmation", : "Confirmation" }
);
function () {
return (
<.>
{({ }) => (
<>
<.>
{...(() => (
<. ={.} ={.}>
<.>
<. />
<.>{.}</.>
</.>
<. />
</.>
))}
</.>
<. ="shipping">
Shipping form here
</.>
<. ="payment">
Payment form here
</.>
<. ="confirmation">
Confirmation here
</.>
<.>
<.>Previous</.>
<.>Next</.>
</.>
</>
)}
</.>
);
}Component Hierarchy
Stepper.Root
├── Stepper.List
│ └── Stepper.Item (for each step)
│ ├── Stepper.Trigger
│ │ ├── Stepper.Indicator
│ │ ├── Stepper.Title
│ │ └── Stepper.Description
│ └── Stepper.Separator
├── Stepper.Content (for each step)
└── Stepper.Actions
├── Stepper.Prev
└── Stepper.NextAccessibility
The primitives implement the WAI-ARIA Tabs pattern so the stepper is announced and operable with keyboard and assistive technologies. The step list is exposed as a tablist, each step trigger as a tab, and each step content as a tabpanel. Stepper semantics are added with aria-current="step" on the active tab.
Roles and attributes by component
| Component | Role / ARIA | Description |
|---|---|---|
| Stepper.Root | role="group", aria-label="Stepper" | Groups the widget. You can override it by passing aria-label on Stepper.Root (e.g. aria-label="Checkout steps"). |
| Stepper.List | role="tablist", aria-orientation | Container for the tabs; orientation is horizontal or vertical from the orientation prop. |
| Stepper.Trigger | role="tab", id="step-{stepId}", aria-controls="step-panel-{stepId}", aria-current="step" (when active), aria-selected (true/false), aria-posinset, aria-setsize | Each trigger is a tab; roving tabindex (only the active tab has tabIndex={0}). |
| Stepper.Content | role="tabpanel", id="step-panel-{stepId}", aria-labelledby="step-{stepId}", tabIndex={0} | Panel for the current step; tabIndex={0} keeps it in the tab order when it has no focusable children. |
| Stepper.Indicator | aria-hidden="true" | Treated as decorative; the trigger’s accessible name comes from the title/content. |
| Stepper.Separator | aria-hidden="true", tabIndex={-1} | Decorative; not in tab order and hidden from assistive tech. |
| Stepper.Prev / Stepper.Next | disabled and aria-disabled when at first/last step | Buttons are disabled and announced as disabled. |
Keyboard interaction
When focus is on a tab (trigger) inside the list:
| Key | Action |
|---|---|
| ArrowRight / ArrowDown | Move focus and activation to the next step. Focus does not wrap: on the last step, ArrowRight/ArrowDown has no effect. |
| ArrowLeft / ArrowUp | Move focus and activation to the previous step. On the first step, ArrowLeft/ArrowUp has no effect. |
| Home | Move focus and activation to the first step. |
| End | Move focus and activation to the last step. |
| Tab | Move focus out of the tablist (e.g. to the active tabpanel or the next focusable element). |
The tabpanel (content) has tabIndex={0} so that when it has no focusable children, users can still Tab into it and then Tab out to the next region.
References
- WAI-ARIA Authoring Practices Guide (APG) – Tabs
- ARIA: tab role (MDN)
- ARIA: tablist role (MDN)
- ARIA: tabpanel role (MDN)
Accessing step data
You can get step data in three ways when using primitives:
Render prop on Stepper.Root
The root accepts a function {({ stepper }) => (...)} that receives the full stepper instance. Use it when you need current step, navigation, flow, lookup, and metadata in the same place you render the list and content:
<Stepper.Root>
{({ stepper }) => (
<>
<div>Current: {stepper.state.current.data.title}</div>
<Stepper.List>...</Stepper.List>
<Stepper.Content step="shipping">...</Stepper.Content>
<Stepper.Actions>
<Stepper.Prev />
<Stepper.Next>{stepper.state.isLast ? "Finish" : "Next"}</Stepper.Next>
</Stepper.Actions>
</>
)}
</Stepper.Root>useStepper() — global stepper in any child
Any component inside Stepper.Root can call useStepper() (from your defineStepper result) to access the full stepper: current step, next(), prev(), goTo(), when(), switch(), etc. Use it for content panels, navigation logic, or any component that needs to know "where we are" and control the flow.
const { Stepper, useStepper } = defineStepper(/* step1, step2, ... */);
function ShippingForm() {
const stepper = useStepper();
return (
<Stepper.Content step="shipping">
<p>Step: {stepper.state.current.data.title}</p>
<button onClick={() => stepper.navigation.next()}>Continue</button>
</Stepper.Content>
);
}useStepItemContext() — this item's step
useStepItemContext() returns the step data, index, status, and metadata for the Stepper.Item that wraps the component that calls the hook. Use it when you need to read or style based on "this step" (e.g. this item's status or index) without passing the step id as a prop.
Import: useStepItemContext is exported from @stepperize/react/primitives (not from the main @stepperize/react entry).
import { useStepItemContext } from "@stepperize/react/primitives";Same type as useStepper().state.current, different value:
useStepper().state.current— The active step (the one the user is on). One value for the whole stepper; it changes when you navigate.useStepItemContext()— This item's step. EachStepper.Itemhas its own step; the hook returns that step'sdataand its computedstatus(active/success/inactive). For example, inside<Stepper.Item step="payment">you get payment's data and payment's status (e.g."inactive"if the user is still on shipping).
When to use: Inside custom trigger wrappers, indicators, titles, or any component rendered inside Stepper.Item that needs this step's data, index, status, or metadata without receiving the step id from the parent.
When not to use: For global stepper state (which step is active, navigation, flow) use useStepper(). For content panels use Stepper.Content or stepper.flow.switch(); those live outside items.
Requirement: The hook must be called from a component that is a descendant of Stepper.Item. Otherwise it throws. For components outside items, use useStepper() instead.
Return value
| Property | Type | Description |
|---|---|---|
data | Step | The step object ({ id, ... }) for this item |
index | number | Zero-based index of this step in the stepper |
status | "active" | "success" | "inactive" | "active" = current step, "success" = past step, "inactive" = future step |
metadata | { get, set, reset } | Get/set/reset metadata for this step (same API as state.current.metadata) |
Example: custom indicator with step number
Use item.index to show a numeric indicator (1, 2, 3) without passing the step id:
import * as React from "react";
import { } from "@stepperize/react";
import { } from "@stepperize/react/primitives";
const { } = (
{ : "shipping", : "Shipping" },
{ : "payment", : "Payment" },
{ : "review", : "Review" }
);
function () {
const = ();
return <>{. + 1}</>;
}
function () {
return (
<.>
{({ }) => (
<.>
{...(() => (
<. ={.} ={.}>
<.>
<.>
< />
</.>
<.>{.}</.>
</.>
<. />
</.>
))}
</.>
)}
</.>
);
}Example: style trigger by status
Use item.status to change the trigger's appearance (e.g. active vs inactive) without comparing step ids yourself:
import * as React from "react";
import { } from "@stepperize/react";
import { } from "@stepperize/react/primitives";
const { } = (
{ : "shipping", : "Shipping" },
{ : "payment", : "Payment" }
);
function ({ }: { : React. }) {
const = ();
const = . === "active";
return (
<.
={() => (
<
="button"
{...}
={ ? "border-blue-500 bg-blue-50" : "border-gray-300 bg-white"}
>
{}
</>
)}
>
{}
</.>
);
}
// Use inside Item
// <Stepper.Item step="shipping">
// <CustomTrigger>
// <Stepper.Indicator />
// <Stepper.Title>Shipping</Stepper.Title>
// </CustomTrigger>
// </Stepper.Item>Example: show icon only when step is completed
Use item.status === "success" to display a checkmark or icon for past steps:
const item = useStepItemContext();
const isCompleted = item.status === "success";
return (
<Stepper.Trigger render={(props) => (
<button {...props}>
{isCompleted ? <CheckIcon /> : <Stepper.Indicator />}
<Stepper.Title>{item.data.title}</Stepper.Title>
</button>
)}>
...
</Stepper.Trigger>
);Summary
| Need | Use |
|---|---|
| This item's step data, index, status, or metadata | useStepItemContext() (inside Stepper.Item) |
| Which step is active; navigation; flow | useStepper() |
| Current step in the same place you render list/content | Render prop on Stepper.Root: {({ stepper }) => ...} |
Stepper.Root
Container component that provides context for all primitives. Internally uses Scoped to manage stepper state.
<Stepper.Root orientation="horizontal">
{({ stepper }) => (
<div>Current: {stepper.state.current.data.title}</div>
)}
</Stepper.Root>Props
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | — | Layout orientation; used for data-orientation and ARIA |
initialStep | Get.Id<Steps> | — | Initial active step (passed to Scoped) |
initialMetadata | Partial<Record<Get.Id<Steps>, Metadata>> | — | Initial metadata (passed to Scoped) |
children | ReactNode | (({ stepper }) => ReactNode) | — | Content or render function |
Data Attributes
| Attribute | Value |
|---|---|
data-component | "stepper" |
data-orientation | Value of orientation prop (if passed) |
Stepper.List
Ordered list container for step items. Renders as <ol> by default.
<Stepper.List>
{stepper.state.all.map((step) => (
<Stepper.Item key={step.id} step={step.id}>
{/* ... */}
</Stepper.Item>
))}
</Stepper.List>Props
| Prop | Type | Default | Description |
|---|---|---|---|
render | (props) => ReactElement | undefined | Custom render function |
children | ReactNode | — | Typically one or more Stepper.Item |
Data Attributes
| Attribute | Value |
|---|---|
data-component | "stepper-list" |
data-orientation | "horizontal" or "vertical" |
Accessibility
role="tablist",aria-orientationfromorientationprop- Keyboard: Arrow keys (prev/next step), Home (first), End (last) — see Accessibility (ARIA)
Stepper.Item
Wrapper for a single step. Provides context for nested primitives.
<Stepper.Item step="shipping">
<Stepper.Trigger>
<Stepper.Indicator />
<Stepper.Title>Shipping</Stepper.Title>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>Props
| Prop | Type | Default | Description |
|---|---|---|---|
step | StepId | Required | The step ID this item represents |
render | (props) => ReactElement | undefined | Custom render function |
children | ReactNode | — | Trigger, separator, etc. |
Data Attributes
| Attribute | Value |
|---|---|
data-component | "stepper-item" |
data-status | "active", "success", or "inactive" |
Stepper.Trigger
Clickable button to navigate to a step. Must be used within Stepper.Item.
<Stepper.Item step="step-1">
<Stepper.Trigger onClick={() => console.log("Custom click")}>
<Stepper.Indicator />
<Stepper.Title>Step 1</Stepper.Title>
</Stepper.Trigger>
</Stepper.Item>Props
| Prop | Type | Default | Description |
|---|---|---|---|
onClick | MouseEventHandler | undefined | Called after the default navigation to the step; use for analytics or side effects. The step still changes. |
disabled | boolean | false | When true, the trigger does not navigate and is not focusable (tabIndex=-1). |
render | (props) => ReactElement | undefined | Custom render function |
children | ReactNode | — | Indicator, title, etc. |
Data Attributes
Same as Stepper.Item.
Accessibility
role="tab",id="step-{stepId}",aria-controls,aria-current="step"(when active),aria-selected,aria-posinset,aria-setsize- Roving tabindex: only the active tab has
tabIndex={0}; inactive tabs havetabIndex={-1} - Keyboard: Arrow keys, Home, End — handled by
Stepper.List; see Accessibility (ARIA)
Stepper.Indicator
Visual indicator for the step (e.g. number or icon). Must be used within Stepper.Item. Renders whatever you pass as children or via render; there is no built-in default content.
{/* Custom: show step index via useStepItemContext or children */}
<Stepper.Indicator>{item.index + 1}</Stepper.Indicator>
{/* Custom: show icon */}
<Stepper.Indicator>
<CheckIcon />
</Stepper.Indicator>Props
| Prop | Type | Default | Description |
|---|---|---|---|
render | (props) => ReactElement | undefined | Custom render function |
children | ReactNode | — | Content (e.g. number, icon) |
Data Attributes
Same as Stepper.Item.
Accessibility
aria-hidden="true"(indicator is decorative, the title provides the accessible name)
Stepper.Title
Renders the step title. Can be used within or outside Stepper.Item.
<Stepper.Item step="shipping">
<Stepper.Trigger>
<Stepper.Title>Shipping Address</Stepper.Title>
</Stepper.Trigger>
</Stepper.Item>Props
| Prop | Type | Default | Description |
|---|---|---|---|
render | (props) => ReactElement | undefined | Custom render function |
children | ReactNode | — | Title text |
Data Attributes
Same as Stepper.Item when used inside an Item.
Stepper.Description
Renders the step description. Can be used within or outside Stepper.Item.
<Stepper.Item step="shipping">
<Stepper.Trigger>
<Stepper.Title>Shipping</Stepper.Title>
<Stepper.Description>Enter your shipping address</Stepper.Description>
</Stepper.Trigger>
</Stepper.Item>Props
| Prop | Type | Default | Description |
|---|---|---|---|
render | (props) => ReactElement | undefined | Custom render function |
children | ReactNode | — | Description text |
Data Attributes
Same as Stepper.Item when used inside an Item.
Stepper.Separator
Visual separator line between steps. Renders between items; hide the last separator in your layout (e.g. by rendering it only when not the last item in the list) or style it via CSS if needed.
<Stepper.List>
{stepper.state.all.map((step, index) => (
<React.Fragment key={step.id}>
<Stepper.Item step={step.id}>
<Stepper.Trigger>...</Stepper.Trigger>
</Stepper.Item>
{index < stepper.state.all.length - 1 && <Stepper.Separator />}
</React.Fragment>
))}
</Stepper.List>Props
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | Inherited | Override separator orientation |
render | (props) => ReactElement | undefined | Custom render function |
Data Attributes
| Attribute | Value |
|---|---|
data-status | "active", "success", or "inactive" (when passed explicitly or inside Item) |
data-orientation | "horizontal" or "vertical" |
Accessibility
aria-hidden="true",tabIndex={-1}— decorative; not in tab order and hidden from assistive tech
Stepper.Content
Panel that displays content for a specific step. Hidden when that step is not the current step.
<Stepper.Content step="shipping">
<ShippingForm />
</Stepper.Content>
<Stepper.Content step="payment">
<PaymentForm />
</Stepper.Content>
<Stepper.Content step="confirmation">
<ConfirmationView />
</Stepper.Content>Props
| Prop | Type | Default | Description |
|---|---|---|---|
step | StepId | Required | The step ID this content is for (shown only when current step matches) |
render | (props) => ReactElement | undefined | Custom render function |
children | ReactNode | — | Content to display |
Data Attributes
| Attribute | Value |
|---|---|
data-component | "stepper-content" |
id | "step-panel-{step}" (for aria-controls from the trigger) |
The component does not set data-status, data-step, data-index, or data-orientation by default; pass them via props if needed.
Accessibility
role="tabpanel",id="step-panel-{stepId}",aria-labelledbylinked to the trigger,tabIndex={0}so the panel is in the tab order when it has no focusable children- Only the panel for the current step is rendered; inactive steps do not render content
Stepper.Actions
Container for navigation buttons. Provides semantic grouping.
<Stepper.Actions>
<Stepper.Prev>Previous</Stepper.Prev>
<Stepper.Next>Next</Stepper.Next>
</Stepper.Actions>Props
| Prop | Type | Default | Description |
|---|---|---|---|
render | (props) => ReactElement | undefined | Custom render function |
children | ReactNode | — | Navigation buttons |
Data Attributes
| Attribute | Value |
|---|---|
data-component | "stepper-actions" |
Accessibility
- No default role or aria-label; add
role="group"andaria-labelvia props if you want the actions region announced.
Stepper.Prev
Button to navigate to the previous step. Automatically disabled on first step.
{/* Default behavior */}
<Stepper.Prev>Previous</Stepper.Prev>
{/* Custom disabled behavior */}
<Stepper.Prev>Back</Stepper.Prev>
{/* Custom click handler */}
<Stepper.Prev onClick={() => {
console.log("Going back");
stepper.navigation.prev();
}}>
Previous
</Stepper.Prev>Props
| Prop | Type | Default | Description |
|---|---|---|---|
onClick | MouseEventHandler | undefined | Called after default prev() |
disabled | boolean | false | Manually disable (default: disabled when stepper.state.isFirst) |
render | (props) => ReactElement | undefined | Custom render function |
children | ReactNode | — | Button content |
Data Attributes
| Attribute | Value |
|---|---|
data-component | "stepper-prev" |
data-disabled | Present when disabled |
Accessibility
disabledandaria-disabledwhen on the first step; provide visible label viachildren(e.g. "Previous", "Back")
Stepper.Next
Button to navigate to the next step. Automatically disabled on last step.
{/* Default behavior */}
<Stepper.Next>Next</Stepper.Next>
{/* Dynamic label */}
<Stepper.Next>
{stepper.state.isLast ? "Finish" : "Next"}
</Stepper.Next>
{/* Custom click with validation */}
<Stepper.Next onClick={async () => {
const isValid = await validateCurrentStep();
if (isValid) {
stepper.navigation.next();
}
}}>
Continue
</Stepper.Next>Props
| Prop | Type | Default | Description |
|---|---|---|---|
onClick | MouseEventHandler | undefined | Called after default next() |
disabled | boolean | false | Manually disable (default: disabled when stepper.state.isLast) |
render | (props) => ReactElement | undefined | Custom render function |
children | ReactNode | — | Button content |
Data Attributes
| Attribute | Value |
|---|---|
data-component | "stepper-next" |
data-disabled | Present when disabled |
Accessibility
disabledandaria-disabledwhen on the last step; provide visible label viachildren(e.g. "Next", "Finish")
Custom Rendering
All primitives support the render prop for full control over the rendered element:
<Stepper.Trigger
render={(props) => (
<button {...props} className="my-custom-trigger">
Custom Trigger
</button>
)}
/>The render function receives all props including data attributes, event handlers, and ARIA attributes. Spread them onto your element (e.g. <button {...props} />) so ARIA and keyboard behavior are preserved.
Styling with Data Attributes
Use data attributes for CSS styling. Step status is one of "active", "success", or "inactive":
/* Active step (current) */
[data-status="active"] {
color: blue;
}
/* Completed step (past) */
[data-status="success"] {
color: green;
}
/* Inactive step (future) */
[data-status="inactive"] {
color: gray;
}
/* Disabled (e.g. Prev/Next when at first/last step) */
[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
/* Horizontal layout */
[data-orientation="horizontal"] {
display: flex;
flex-direction: row;
}
/* Vertical layout */
[data-orientation="vertical"] {
display: flex;
flex-direction: column;
}Complete Example
import * as React from "react"
import { } from "@stepperize/react"
const { , } = (
{ : "account", : "Account", : "Create your account" },
{ : "profile", : "Profile", : "Set up your profile" },
{ : "complete", : "Complete", : "All done!" }
);
function () {
return (
<. ="horizontal">
{({ }) => (
< ="stepper-container">
{/* Step list */}
<. ="stepper-list">
{...(() => (
<.
={.}
={.}
="stepper-item"
>
<. ="stepper-trigger">
<. ="stepper-indicator" />
< ="stepper-text">
<. ="stepper-title">
{.}
</.>
<. ="stepper-description">
{.}
</.>
</>
</.>
<. ="stepper-separator" />
</.>
))}
</.>
{/* Step content */}
< ="stepper-content-container">
<. ="account">
<>Create Account</>
<>Enter your email and password</>
</.>
<. ="profile">
<>Set Up Profile</>
<>Tell us about yourself</>
</.>
<. ="complete">
<>All Done!</>
<>Your account is ready</>
</.>
</>
{/* Navigation */}
<. ="stepper-actions">
<. ="stepper-prev">
Previous
</.>
<. ="stepper-next">
{.. ? "Finish" : "Next"}
</.>
</.>
</>
)}
</.>
);
}Last updated on