ComponentsPanel
Stepper
Guides people through a task in numbered steps, showing where they are and what is done.
Import
import { Stepper, StepperList, StepperItem, StepperTrigger, StepperIndicator, StepperTitle, StepperDescription, StepperSeparator, StepperContent, StepperPrevious, StepperNext } from "@booleanpress/ui/stepper"Usage
A stepper splits a task into steps shown as an ordered list: connecting a mailer, verifying a domain. Each step is a button that goes to it, the current one is marked, and the steps before it show a check. For pages of the same list, use pagination; for views of one thing, tabs.
import { Stepper, StepperContent, StepperIndicator, StepperItem, StepperList, StepperNext, StepperPrevious, StepperSeparator, StepperTitle, StepperTrigger } from "@booleanpress/ui/stepper"
export function Setup() {
return (
<Stepper defaultValue={1}>
<StepperList>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator /><StepperTitle>Connection</StepperTitle></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator /><StepperTitle>Sender</StepperTitle></StepperTrigger>
</StepperItem>
</StepperList>
<StepperContent step={1}>Host and port</StepperContent>
<StepperContent step={2}>From name and address</StepperContent>
<StepperPrevious />
<StepperNext />
</Stepper>
)
}The value is the current step's number, counting from 1: uncontrolled with defaultValue, or controlled with value and onValueChange. Every StepperItem takes its step; completed marks it done (by default every step before the current one is), error marks it as needing attention, disabled turns its button off. linear stops people choosing a step after the current one from the list: they move on with StepperNext. StepperNext and StepperPrevious are buttons that move one step, labelled from the provider's next, back and, on the last step, finish strings; call event.preventDefault() in onClick to keep people on a step whose fields are not valid yet. On the last step StepperNext only calls your onClick.
orientation="vertical" stacks the steps; put each step's StepperContent inside its StepperItem, after the separator, where it takes the item's step. Leave out StepperContent for a list of steps alone. StepperIndicator shows the number, a check when done and a cross on error; children replace them.
Examples
Horizontal
Three steps in a row, the current step's content below them.
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperList,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@booleanpress/ui/stepper"
const STEPS = ["Connection", "Sender", "Test email"]
export default function StepperHorizontal() {
return (
<Stepper defaultValue={1} className="w-full max-w-2xl">
<StepperList>
{STEPS.map((title, index) => (
<StepperItem key={title} step={index + 1}>
<StepperTrigger>
<StepperIndicator />
<StepperTitle>{title}</StepperTitle>
</StepperTrigger>
{index < STEPS.length - 1 ? <StepperSeparator /> : null}
</StepperItem>
))}
</StepperList>
{STEPS.map((title, index) => (
<StepperContent key={title} step={index + 1}>
<div className="flex h-48 items-center justify-center rounded-sm border-2 border-dashed bg-subtle font-medium dark:bg-background">
{title} settings
</div>
</StepperContent>
))}
</Stepper>
)
}Vertical
orientation="vertical": each step's content opens under it, beside the line.
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperList,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@booleanpress/ui/stepper"
const STEPS = ["Connection", "Sender", "Test email"]
export default function StepperVertical() {
return (
<Stepper orientation="vertical" defaultValue={1} className="w-full max-w-2xl">
<StepperList>
{STEPS.map((title, index) => (
<StepperItem key={title} step={index + 1}>
<StepperTrigger>
<StepperIndicator />
<StepperTitle>{title}</StepperTitle>
</StepperTrigger>
{index < STEPS.length - 1 ? <StepperSeparator /> : null}
{/* In a vertical stepper each step's content sits in its own item, beside the line. */}
<StepperContent>
<div className="flex h-48 items-center justify-center rounded-sm border-2 border-dashed bg-subtle font-medium dark:bg-background">
{title} settings
</div>
</StepperContent>
</StepperItem>
))}
</StepperList>
</Stepper>
)
}Linear
linear: later steps cannot be chosen from the list; Back and Next move one step.
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperList,
StepperNext,
StepperPrevious,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@booleanpress/ui/stepper"
const STEPS = ["Connection", "Sender", "Test email"]
export default function StepperLinear() {
return (
<Stepper linear defaultValue={1} className="w-full max-w-2xl">
<StepperList>
{STEPS.map((title, index) => (
<StepperItem key={title} step={index + 1}>
<StepperTrigger>
<StepperIndicator />
<StepperTitle>{title}</StepperTitle>
</StepperTrigger>
{index < STEPS.length - 1 ? <StepperSeparator /> : null}
</StepperItem>
))}
</StepperList>
{STEPS.map((title, index) => (
<StepperContent key={title} step={index + 1}>
<div className="flex h-48 items-center justify-center rounded-sm border-2 border-dashed bg-subtle font-medium dark:bg-background">
{title} settings
</div>
</StepperContent>
))}
<div className="flex justify-between px-1.5">
<StepperPrevious />
<StepperNext />
</div>
</Stepper>
)
}Steps only
The list without content panels, as a ticket's status.
import {
Stepper,
StepperIndicator,
StepperItem,
StepperList,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@booleanpress/ui/stepper"
const STEPS = ["Received", "In progress", "Resolved"]
export default function StepperStepsOnly() {
return (
<Stepper defaultValue={1} className="w-full max-w-xl">
<StepperList aria-label="Ticket status">
{STEPS.map((title, index) => (
<StepperItem key={title} step={index + 1}>
<StepperTrigger>
<StepperIndicator />
<StepperTitle>{title}</StepperTitle>
</StepperTrigger>
{index < STEPS.length - 1 ? <StepperSeparator /> : null}
</StepperItem>
))}
</StepperList>
</Stepper>
)
}Custom indicator
Icons in larger circles replace the numbers, the current one filled; the titles are visually hidden but still name the steps.
import { AtSignIcon, SendIcon, ServerIcon } from "lucide-react"
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperList,
StepperNext,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@booleanpress/ui/stepper"
const STEPS = [
{ title: "Connection", icon: ServerIcon, text: "Choose the mail server and its port." },
{ title: "Sender", icon: AtSignIcon, text: "Set the name and address emails come from." },
{ title: "Test email", icon: SendIcon, text: "Send a test to check that mail arrives." },
]
export default function StepperCustomIndicator() {
return (
<Stepper defaultValue={1} className="w-full max-w-xl">
<StepperList aria-label="Mailer setup">
{STEPS.map((step, index) => (
<StepperItem key={step.title} step={index + 1}>
<StepperTrigger>
{/* Children replace the number; the current step's circle fills with the primary colour. */}
<StepperIndicator className="size-12 data-[state=active]:border-primary data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
<step.icon />
</StepperIndicator>
<StepperTitle className="sr-only">{step.title}</StepperTitle>
</StepperTrigger>
{index < STEPS.length - 1 ? <StepperSeparator /> : null}
</StepperItem>
))}
</StepperList>
{STEPS.map((step, index) => (
<StepperContent key={step.title} step={index + 1} className="flex flex-col items-center gap-4 text-center">
<h4 className="text-xl font-semibold">{step.title}</h4>
<p className="text-muted-foreground">{step.text}</p>
<StepperNext className="self-end" />
</StepperContent>
))}
</Stepper>
)
}With descriptions
A line of detail under each title; the step before the current one shows a check.
import {
Stepper,
StepperDescription,
StepperIndicator,
StepperItem,
StepperList,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@booleanpress/ui/stepper"
const STEPS = [
{ title: "Domain", description: "Add example.com" },
{ title: "DNS records", description: "SPF, DKIM and DMARC" },
{ title: "Verify", description: "Usually under an hour" },
]
export default function StepperWithDescriptions() {
return (
<Stepper defaultValue={2} className="w-full max-w-2xl">
<StepperList aria-label="Domain setup">
{STEPS.map((step, index) => (
<StepperItem key={step.title} step={index + 1}>
<StepperTrigger>
<StepperIndicator />
<StepperTitle>{step.title}</StepperTitle>
<StepperDescription>{step.description}</StepperDescription>
</StepperTrigger>
{index < STEPS.length - 1 ? <StepperSeparator /> : null}
</StepperItem>
))}
</StepperList>
</Stepper>
)
}Error state
error draws a red cross and title on the step that needs attention.
import {
Stepper,
StepperDescription,
StepperIndicator,
StepperItem,
StepperList,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@booleanpress/ui/stepper"
const STEPS = [
{ title: "Connection", description: "smtp.example.com:587" },
{ title: "Authentication", description: "535: credentials rejected", error: true },
{ title: "Test email", description: "Not sent yet" },
]
export default function StepperErrorState() {
return (
<Stepper defaultValue={2} className="w-full max-w-2xl">
<StepperList aria-label="Connection check">
{STEPS.map((step, index) => (
<StepperItem key={step.title} step={index + 1} error={step.error}>
<StepperTrigger>
<StepperIndicator />
<StepperTitle>{step.title}</StepperTitle>
<StepperDescription>{step.description}</StepperDescription>
</StepperTrigger>
{index < STEPS.length - 1 ? <StepperSeparator /> : null}
</StepperItem>
))}
</StepperList>
</Stepper>
)
}Wizard
Connecting a mailer: Next stays on the API key step and marks it with an error until a key is pasted, then a review ends with Finish.
import { useState } from "react"
import { Field, FieldError, FieldLabel } from "@booleanpress/ui/field"
import { Input } from "@booleanpress/ui/input"
import { RadioGroup, RadioGroupItem } from "@booleanpress/ui/radio-group"
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperList,
StepperNext,
StepperPrevious,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@booleanpress/ui/stepper"
const STEPS = ["Provider", "API key", "Review"]
const PROVIDERS = ["Amazon SES", "Mailgun", "Postmark"]
export default function StepperWizard() {
const [step, setStep] = useState(1)
const [provider, setProvider] = useState("Mailgun")
const [apiKey, setApiKey] = useState("")
const [missingKey, setMissingKey] = useState(false)
const [connected, setConnected] = useState(false)
return (
<Stepper linear value={step} onValueChange={setStep} className="w-full max-w-xl">
<StepperList aria-label="Connect a mailer">
{STEPS.map((title, index) => (
<StepperItem key={title} step={index + 1} error={index === 1 && missingKey}>
<StepperTrigger>
<StepperIndicator />
<StepperTitle>{title}</StepperTitle>
</StepperTrigger>
{index < STEPS.length - 1 ? <StepperSeparator /> : null}
</StepperItem>
))}
</StepperList>
<StepperContent step={1}>
<RadioGroup aria-label="Provider" value={provider} onValueChange={setProvider}>
{PROVIDERS.map((name) => (
<Field key={name} orientation="horizontal">
<RadioGroupItem id={`provider-${name}`} value={name} />
<FieldLabel htmlFor={`provider-${name}`}>{name}</FieldLabel>
</Field>
))}
</RadioGroup>
</StepperContent>
<StepperContent step={2}>
<Field data-invalid={missingKey || undefined}>
<FieldLabel htmlFor="wizard-key">{provider} API key</FieldLabel>
<Input
id="wizard-key"
value={apiKey}
aria-invalid={missingKey || undefined}
aria-describedby={missingKey ? "wizard-key-error" : undefined}
onChange={(event) => setApiKey(event.target.value)}
/>
{missingKey ? <FieldError id="wizard-key-error">Paste the key to continue.</FieldError> : null}
</Field>
</StepperContent>
<StepperContent step={3}>
<p>{connected ? `${provider} is connected.` : `Connect ${provider} with the key ending ${apiKey.slice(-4)}?`}</p>
</StepperContent>
<div className="flex justify-between px-1.5">
<StepperPrevious />
<StepperNext
disabled={connected}
onClick={(event) => {
const blocked = step === 2 && apiKey.trim() === ""
setMissingKey(blocked)
if (blocked) event.preventDefault()
if (step === 3) setConnected(true)
}}
/>
</div>
</Stepper>
)
}Accessibility
- Semantics
- The steps are an ordered list (
ol); each step is abutton, and the current one hasaria-current="step". A step's content is agroupnamed by its step's button. Steps a linear stepper blocks aredisabledbuttons. - Labels
- Each button is named in words, from the provider's strings: "Step 2 of 3", the title and description, then "Completed" or "Has errors". The drawn number, check and cross are hidden from assistive technology. Name the list with
aria-labelwhen the page has more than one. - Focus
- Every step that can be chosen is a tab stop, in order; the arrow keys also move between them. Choosing a step keeps focus on its button. When Back disables on the first step while it has focus, focus moves to Next (and from a Next you disable to Back), so it never falls to the page. Focus is a 1px
--ringoutline 2px outside the button. - Known limits
- A step's content is not announced when it changes: move focus into it yourself if the new step needs it, for example to its first field.
- Disabled steps are out of the tab order; their titles are still read in the list.
- Titles truncate with an ellipsis when the row is narrow; the full title stays in the button's name.
Keyboard
| Key | Behaviour |
|---|---|
| Tab | Moves to the next step that can be chosen, then into the content. |
| EnterorSpace | Goes to the focused step. |
| โorโ | Moves to the next or previous step, wrapping at the ends (โ and โ when vertical). Reversed in a right-to-left page. |
| HomeorEnd | Moves to the first or last step that can be chosen. |
API
Stepper
Renders a div and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | number | 1 | The step it starts on, when it controls itself. 1 by default. |
linear | boolean | false | Steps after the current one cannot be chosen from the list: StepperNext (after your checks) moves on. |
onValueChange | ((step: number) => void) | Called with the step's number when a step's button, StepperPrevious or StepperNext changes the step. | |
orientation | "horizontal" | "vertical" | horizontal | horizontal (default): steps in a row, content below; vertical: steps in a column, each step's content under it. |
value | number | The current step's number, when you control it. Pair it with onValueChange. |
StepperList
Renders a ol and passes it every other prop.
StepperItem
Renders a li and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
steprequired | number | The step's number, counting from 1. Required. | |
completed | boolean | Marks the step done: a check in its circle. By default every step before the current one is done. | |
disabled | boolean | false | The step's button cannot be used. |
error | boolean | false | Marks the step as needing attention: a cross in a red circle, the title in red. |
StepperTrigger
Renders a button and passes it every other prop.
StepperIndicator
Renders a span and passes it every other prop.
StepperTitle
Renders a span and passes it every other prop.
StepperDescription
Renders a span and passes it every other prop.
StepperSeparator
Renders a span and passes it every other prop.
StepperContent
Renders a div and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
forceMount | boolean | false | Keep the content in the page while its step is not current, hidden. |
step | number | The step whose content this is. Inside a StepperItem (vertical), that item's step by default. |
StepperPrevious
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | ||
loading | boolean | Shows the spinner in place of the leading icon, sets aria-busy and aria-disabled and ignores presses (a click,
Enter, Space, a form's submission), while the button keeps its focus and its place in the tab order. With
asChild the child (a link) gets the same. | |
raised | boolean | null | ||
rounded | boolean | null | ||
severity | "success" | "info" | "warning" | "help" | "danger" | "contrast" | null | ||
size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | ||
variant | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | secondary | Button's look; secondary by default. |
StepperNext
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | ||
loading | boolean | Shows the spinner in place of the leading icon, sets aria-busy and aria-disabled and ignores presses (a click,
Enter, Space, a form's submission), while the button keeps its focus and its place in the tab order. With
asChild the child (a link) gets the same. | |
raised | boolean | null | ||
rounded | boolean | null | ||
severity | "success" | "info" | "warning" | "help" | "danger" | "contrast" | null | ||
size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | ||
variant | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null |
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="stepper" (Stepper), data-slot="stepper-trigger" (StepperList), data-slot="stepper-item" (StepperItem), data-slot="stepper-trigger" (StepperTrigger), data-slot="stepper-indicator" (StepperIndicator), data-slot="stepper-title" (StepperTitle), data-slot="stepper-description" (StepperDescription), data-slot="stepper-separator" (StepperSeparator), data-slot="stepper-content" (StepperContent), data-slot="stepper-previous" (StepperPrevious), data-slot="stepper-next" (StepperNext), and data-orientation, data-linear, data-state, data-error, data-disabled, data-last.
Provider strings: stepOf, stepError, stepCompleted, back, finish, next (BooleanUIProvider's strings).
Theming
The circle is --card with a 2px --border edge; its number is --muted-foreground, the current step's and the check --primary; an error is --invalid and --destructive-strong. Titles are 14px medium in --muted-foreground, the current one --primary. The line after a completed step is --primary, the others --border.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--border | border, background |
--card | background |
--card-foreground | text |
--destructive-strong | text |
--invalid | border |
--muted-foreground | text |
--primary | text, background |
--ring | outline |