Use steppers to visualize a group of connected actions or the order of a workflow.
import {Stepper} from "@qualcomm-ui/react/stepper"Examples
Linear Mode
The stepper is linear by default (linear is true). Steps must be completed in order.
- Backward navigation is always allowed
- Previously visited steps can be re-clicked without restriction
- Forward navigation is restricted to the immediate next step
- canGoToStep can override forward navigation decisions
import {ChevronLeft, ChevronRight} from "lucide-react"
import {Button} from "@qualcomm-ui/react/button"
import {Stepper} from "@qualcomm-ui/react/stepper"
const items = [
{description: "Contact Info", title: "First", value: "first"},
{description: "Date & Time", title: "Second", value: "second"},
{description: "Select Rooms", title: "Third", value: "third"},
]
export function StepperLinearDemo() {
return (
<Stepper.Root count={items.length}>
<Stepper.List>
{items.map((item, index) => (
<Stepper.Item key={item.value} index={index}>
<Stepper.Trigger>
<Stepper.Indicator>{index + 1}</Stepper.Indicator>
<Stepper.Label>{item.title}</Stepper.Label>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>
))}
</Stepper.List>
{items.map((item, index) => (
<Stepper.Content key={item.value} index={index}>
{item.title} - {item.description}
</Stepper.Content>
))}
<Stepper.CompletedContent>All steps completed.</Stepper.CompletedContent>
<div className="mt-6 flex justify-between">
<Stepper.PrevTrigger>
<Button size="sm" startIcon={ChevronLeft} variant="outline">
Back
</Button>
</Stepper.PrevTrigger>
<Stepper.NextTrigger>
<Button endIcon={ChevronRight} size="sm" variant="outline">
Next
</Button>
</Stepper.NextTrigger>
</div>
</Stepper.Root>
)
}Non-linear Mode
Set linear to false to allow accessing any step at any time. Use the completed prop to manually track which steps are done.
canGoToStep applies to all navigation if provided. Otherwise, all navigation is allowed.
Icon
Use the <Stepper.IndicatorIcon> component to use an icon for each step's indicator.
import type {ReactElement} from "react"
import {
ChevronLeft,
ChevronRight,
ChevronsUp,
DollarSign,
User,
} from "lucide-react"
import {Button} from "@qualcomm-ui/react/button"
import {Stepper} from "@qualcomm-ui/react/stepper"
const items = [
{content: "Contact details", icon: User, title: "Step 1", value: "step-1"},
{content: "Payment info", icon: DollarSign, title: "Step 2", value: "step-2"},
{content: "Confirmation", icon: ChevronsUp, title: "Step 3", value: "step-3"},
]
// TODO: icon API is still being finalized
export function StepperIconDemo(): ReactElement {
return (
<Stepper.Root count={items.length}>
<Stepper.List>
{items.map((item, index) => (
<Stepper.Item key={item.value} index={index}>
<Stepper.Trigger>
<Stepper.Indicator>
<Stepper.IndicatorIcon icon={item.icon} />
</Stepper.Indicator>
<Stepper.Label>{item.title}</Stepper.Label>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>
))}
</Stepper.List>
{items.map((item, index) => (
<Stepper.Content key={item.value} index={index}>
{item.content}
</Stepper.Content>
))}
<Stepper.CompletedContent>All steps completed.</Stepper.CompletedContent>
<div className="mt-6 flex justify-between">
<Stepper.PrevTrigger>
<Button size="sm" startIcon={ChevronLeft} variant="outline">
Back
</Button>
</Stepper.PrevTrigger>
<Stepper.NextTrigger>
<Button endIcon={ChevronRight} size="sm" variant="outline">
Next
</Button>
</Stepper.NextTrigger>
</div>
</Stepper.Root>
)
}Hint
Add a hint to provide additional context below the step label.
import type {ReactElement} from "react"
import {ChevronLeft, ChevronRight} from "lucide-react"
import {Button} from "@qualcomm-ui/react/button"
import {Stepper} from "@qualcomm-ui/react/stepper"
const items = [
{
content: "Enter your contact information",
hint: "Contact details",
title: "Step 1",
value: "step-1",
},
{
content: "Provide your payment details",
hint: "Payment info",
title: "Step 2",
value: "step-2",
},
{
content: "Review and confirm your order",
hint: "Confirmation",
title: "Step 3",
value: "step-3",
},
]
export function StepperHintDemo(): ReactElement {
return (
<Stepper.Root count={items.length} defaultStep={1}>
<Stepper.List>
{items.map((item, index) => (
<Stepper.Item key={item.value} index={index}>
<Stepper.Trigger>
<Stepper.Indicator>{index + 1}</Stepper.Indicator>
<Stepper.Label>{item.title}</Stepper.Label>
<Stepper.Hint>{item.hint}</Stepper.Hint>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>
))}
</Stepper.List>
{items.map((item, index) => (
<Stepper.Content key={item.value} index={index}>
{item.content}
</Stepper.Content>
))}
<Stepper.CompletedContent>All steps completed.</Stepper.CompletedContent>
<div className="mt-6 flex justify-between">
<Stepper.PrevTrigger>
<Button size="sm" startIcon={ChevronLeft} variant="outline">
Back
</Button>
</Stepper.PrevTrigger>
<Stepper.NextTrigger>
<Button endIcon={ChevronRight} size="sm" variant="outline">
Next
</Button>
</Stepper.NextTrigger>
</div>
</Stepper.Root>
)
}Layout & Orientation
Stepper orientation and label placement are controlled through the orientation prop.
Horizontal
horizontal (default): The step list is centered horizontally, and the labels are stacked vertically and centered below the step indicator.
import type {ReactElement} from "react"
import {ChevronLeft, ChevronRight} from "lucide-react"
import {Button} from "@qualcomm-ui/react/button"
import {Stepper} from "@qualcomm-ui/react/stepper"
const items = [
{
content: "Enter your contact information",
hint: "Contact details",
title: "Step 1",
value: "step-1",
},
{
content: "Provide your payment details",
hint: "Payment info",
title: "Step 2",
value: "step-2",
},
{
content: "Review and confirm your order",
hint: "Confirmation",
title: "Step 3",
value: "step-3",
},
]
export function StepperHorizontalDemo(): ReactElement {
return (
<Stepper.Root count={items.length} defaultStep={1}>
<Stepper.List>
{items.map((item, index) => (
<Stepper.Item key={item.value} index={index}>
<Stepper.Trigger>
<Stepper.Indicator>{index + 1}</Stepper.Indicator>
<Stepper.Label>{item.title}</Stepper.Label>
<Stepper.Hint>{item.hint}</Stepper.Hint>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>
))}
</Stepper.List>
{items.map((item, index) => (
<Stepper.Content key={item.value} index={index}>
{item.content}
</Stepper.Content>
))}
<Stepper.CompletedContent>All steps completed.</Stepper.CompletedContent>
<div className="mt-6 flex justify-between">
<Stepper.PrevTrigger>
<Button size="sm" startIcon={ChevronLeft} variant="outline">
Back
</Button>
</Stepper.PrevTrigger>
<Stepper.NextTrigger>
<Button endIcon={ChevronRight} size="sm" variant="outline">
Next
</Button>
</Stepper.NextTrigger>
</div>
</Stepper.Root>
)
}Horizontal Inline
horizontal-inline is a variant of horizontal that places the step indicator and labels inline. The step items are left-aligned and take the full width of their parent container
import {ChevronLeft, ChevronRight} from "lucide-react"
import {Button} from "@qualcomm-ui/react/button"
import {Stepper} from "@qualcomm-ui/react/stepper"
const items = [
{description: "Contact Info", title: "First", value: "first"},
{description: "Date & Time", title: "Second", value: "second"},
{description: "Select Rooms", title: "Third", value: "third"},
]
export function StepperHorizontalInlineDemo() {
return (
<Stepper.Root count={items.length} orientation="horizontal-inline">
<Stepper.List>
{items.map((item, index) => (
<Stepper.Item key={item.value} index={index}>
<Stepper.Trigger>
<Stepper.Indicator>{index + 1}</Stepper.Indicator>
<Stepper.Label>{item.title}</Stepper.Label>
<Stepper.Hint>{item.description}</Stepper.Hint>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>
))}
</Stepper.List>
{items.map((item, index) => (
<Stepper.Content key={item.value} index={index}>
{item.title} - {item.description}
</Stepper.Content>
))}
<Stepper.CompletedContent>All steps completed.</Stepper.CompletedContent>
<div className="mt-6 flex justify-between">
<Stepper.PrevTrigger>
<Button size="sm" startIcon={ChevronLeft} variant="outline">
Back
</Button>
</Stepper.PrevTrigger>
<Stepper.NextTrigger>
<Button endIcon={ChevronRight} size="sm" variant="outline">
Next
</Button>
</Stepper.NextTrigger>
</div>
</Stepper.Root>
)
}Horizontal Bottom Start
horizontal-bottom-start is a variant of horizontal that places the labels below each step, aligned to the start. The items are left-aligned and take the full width of their parent container.
import {ChevronLeft, ChevronRight} from "lucide-react"
import {Button} from "@qualcomm-ui/react/button"
import {Stepper} from "@qualcomm-ui/react/stepper"
const items = [
{description: "Contact Info", title: "First", value: "first"},
{description: "Date & Time", title: "Second", value: "second"},
{description: "Select Rooms", title: "Third", value: "third"},
]
export function StepperHorizontalBottomStartDemo() {
return (
<Stepper.Root count={items.length} orientation="horizontal-bottom-start">
<Stepper.List>
{items.map((item, index) => (
<Stepper.Item key={item.value} index={index}>
<Stepper.Trigger>
<Stepper.Indicator>{index + 1}</Stepper.Indicator>
<Stepper.Label>{item.title}</Stepper.Label>
<Stepper.Hint>{item.description}</Stepper.Hint>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>
))}
</Stepper.List>
{items.map((item, index) => (
<Stepper.Content key={item.value} index={index}>
{item.title} - {item.description}
</Stepper.Content>
))}
<Stepper.CompletedContent>All steps completed.</Stepper.CompletedContent>
<div className="mt-6 flex justify-between">
<Stepper.PrevTrigger>
<Button size="sm" startIcon={ChevronLeft} variant="outline">
Back
</Button>
</Stepper.PrevTrigger>
<Stepper.NextTrigger>
<Button endIcon={ChevronRight} size="sm" variant="outline">
Next
</Button>
</Stepper.NextTrigger>
</div>
</Stepper.Root>
)
}Vertical
Set orientation to vertical to stack the steps vertically. The labels are centered relative to the step indicator.
import type {ReactElement} from "react"
import {ChevronLeft, ChevronRight} from "lucide-react"
import {Button} from "@qualcomm-ui/react/button"
import {Stepper} from "@qualcomm-ui/react/stepper"
const items = [
{content: "Contact details", title: "Step 1", value: "step-1"},
{content: "Payment info", title: "Step 2", value: "step-2"},
{content: "Confirmation", title: "Step 3", value: "step-3"},
]
export function StepperVerticalDemo(): ReactElement {
return (
<Stepper.Root
className="min-h-96"
count={items.length}
orientation="vertical"
>
<Stepper.List>
{items.map((item, index) => (
<Stepper.Item key={item.value} index={index}>
<Stepper.Trigger>
<Stepper.Indicator>{index + 1}</Stepper.Indicator>
<Stepper.Label>{item.title}</Stepper.Label>
<Stepper.Hint>{item.content}</Stepper.Hint>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>
))}
</Stepper.List>
<div className="flex flex-col gap-4">
{items.map((item, index) => (
<Stepper.Content key={item.value} index={index}>
{item.content}
</Stepper.Content>
))}
<Stepper.CompletedContent>
All steps completed.
</Stepper.CompletedContent>
<div className="mt-6 flex gap-2">
<Stepper.PrevTrigger>
<Button size="sm" startIcon={ChevronLeft} variant="outline">
Back
</Button>
</Stepper.PrevTrigger>
<Stepper.NextTrigger>
<Button endIcon={ChevronRight} size="sm" variant="outline">
Next
</Button>
</Stepper.NextTrigger>
</div>
</div>
</Stepper.Root>
)
}Vertical Inline
Set orientation to vertical-inline to stack the steps vertically while aligning the labels inline with the step indicator.
import type {ReactElement} from "react"
import {ChevronLeft, ChevronRight} from "lucide-react"
import {Button} from "@qualcomm-ui/react/button"
import {Stepper} from "@qualcomm-ui/react/stepper"
const items = [
{content: "Contact details", title: "Step 1", value: "step-1"},
{content: "Payment info", title: "Step 2", value: "step-2"},
{content: "Confirmation", title: "Step 3", value: "step-3"},
]
export function StepperVerticalInlineDemo(): ReactElement {
return (
<Stepper.Root
className="min-h-96"
count={items.length}
orientation="vertical-inline"
>
<Stepper.List>
{items.map((item, index) => (
<Stepper.Item key={item.value} index={index}>
<Stepper.Trigger>
<Stepper.Indicator>{index + 1}</Stepper.Indicator>
<Stepper.Label>{item.title}</Stepper.Label>
<Stepper.Hint>{item.content}</Stepper.Hint>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>
))}
</Stepper.List>
<div className="flex flex-col gap-4">
{items.map((item, index) => (
<Stepper.Content key={item.value} index={index}>
{item.content}
</Stepper.Content>
))}
<Stepper.CompletedContent>
All steps completed.
</Stepper.CompletedContent>
<div className="mt-6 flex gap-2">
<Stepper.PrevTrigger>
<Button size="sm" startIcon={ChevronLeft} variant="outline">
Back
</Button>
</Stepper.PrevTrigger>
<Stepper.NextTrigger>
<Button endIcon={ChevronRight} size="sm" variant="outline">
Next
</Button>
</Stepper.NextTrigger>
</div>
</div>
</Stepper.Root>
)
}Controlled State
Use step and onStepChange to control the active step externally.
import {type ReactElement, useState} from "react"
import {ChevronLeft, ChevronRight} from "lucide-react"
import {Button} from "@qualcomm-ui/react/button"
import {Stepper} from "@qualcomm-ui/react/stepper"
const items = [
{content: "Contact details", title: "Step 1", value: "step-1"},
{content: "Payment info", title: "Step 2", value: "step-2"},
{content: "Confirmation", title: "Step 3", value: "step-3"},
]
export function StepperControlledDemo(): ReactElement {
const [step, setStep] = useState(0)
return (
<Stepper.Root count={items.length} onStepChange={setStep} step={step}>
<Stepper.List>
{items.map((item, index) => (
<Stepper.Item key={item.value} index={index}>
<Stepper.Trigger>
<Stepper.Indicator>{index + 1}</Stepper.Indicator>
<Stepper.Label>{item.title}</Stepper.Label>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>
))}
</Stepper.List>
{items.map((item, index) => (
<Stepper.Content key={item.value} index={index}>
{item.content}
</Stepper.Content>
))}
<Stepper.CompletedContent>All steps completed.</Stepper.CompletedContent>
<div className="mt-6 flex justify-between">
<Stepper.PrevTrigger>
<Button size="sm" startIcon={ChevronLeft} variant="outline">
Back
</Button>
</Stepper.PrevTrigger>
<Stepper.NextTrigger>
<Button endIcon={ChevronRight} size="sm" variant="outline">
Next
</Button>
</Stepper.NextTrigger>
</div>
</Stepper.Root>
)
}Manual Completion
Set linear to false and use the completed prop to manually control which steps are marked as complete.
import {type ReactElement, useState} from "react"
import {ChevronLeft, ChevronRight} from "lucide-react"
import {Button} from "@qualcomm-ui/react/button"
import {Stepper} from "@qualcomm-ui/react/stepper"
const items = [
{title: "Account", value: "account"},
{title: "Profile", value: "profile"},
{title: "Review", value: "review"},
]
export function StepperCompletedDemo(): ReactElement {
const [completed, setCompleted] = useState<Record<number, boolean>>({})
function toggleCompleted(index: number) {
setCompleted((prev) => ({...prev, [index]: !prev[index]}))
}
const allCompleted = items.every((_, i) => completed[i])
return (
<Stepper.Root completed={completed} count={items.length} linear={false}>
<Stepper.List>
{items.map((item, index) => (
<Stepper.Item key={item.value} index={index}>
<Stepper.Trigger>
<Stepper.Indicator>{index + 1}</Stepper.Indicator>
<Stepper.Label>{item.title}</Stepper.Label>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>
))}
</Stepper.List>
{items.map((item, index) => (
<Stepper.Content key={item.value} index={index}>
<div className="flex items-center gap-4">
<span>{item.title} content</span>
<Button
onClick={() => toggleCompleted(index)}
size="sm"
variant={completed[index] ? "outline" : "fill"}
>
{completed[index] ? "Mark Incomplete" : "Mark Complete"}
</Button>
</div>
</Stepper.Content>
))}
<Stepper.CompletedContent>All steps completed.</Stepper.CompletedContent>
<div className="mt-6 flex justify-between">
<Stepper.PrevTrigger>
<Button size="sm" startIcon={ChevronLeft} variant="outline">
Back
</Button>
</Stepper.PrevTrigger>
<Stepper.Context>
{({count, hasNextStep, step}) => (
<Stepper.NextTrigger>
<Button
disabled={!hasNextStep || (step === count - 1 && !allCompleted)}
endIcon={ChevronRight}
size="sm"
variant="outline"
>
Next
</Button>
</Stepper.NextTrigger>
)}
</Stepper.Context>
</div>
</Stepper.Root>
)
}Pending Steps
Use the pending prop to mark steps as pending. This is typically used to indicate that a step has been visited but not completed.
import {type ReactElement, useState} from "react"
import {ChevronLeft, ChevronRight} from "lucide-react"
import {Button} from "@qualcomm-ui/react/button"
import {Stepper} from "@qualcomm-ui/react/stepper"
const items = [
{title: "Account", value: "account"},
{title: "Profile", value: "profile"},
{title: "Review", value: "review"},
]
export function StepperPendingDemo(): ReactElement {
const [pendingStep, setPendingStep] = useState<number>(1)
return (
<Stepper.Root count={items.length} pending={{[pendingStep]: true}}>
<Stepper.List>
{items.map((item, index) => (
<Stepper.Item key={item.value} index={index}>
<Stepper.Trigger>
<Stepper.Indicator>{index + 1}</Stepper.Indicator>
<Stepper.Label>{item.title}</Stepper.Label>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>
))}
</Stepper.List>
{items.map((item, index) => (
<Stepper.Content key={item.value} index={index}>
<div className="flex items-center gap-4">
<span>{item.title} content</span>
</div>
</Stepper.Content>
))}
<Stepper.CompletedContent>All steps completed.</Stepper.CompletedContent>
<Stepper.Context>
{({step}) => (
<div className="mt-6 flex justify-between">
<Stepper.PrevTrigger>
<Button size="sm" startIcon={ChevronLeft} variant="outline">
Back
</Button>
</Stepper.PrevTrigger>
<Stepper.NextTrigger>
<Button
endIcon={ChevronRight}
onClick={() => setPendingStep(step + 1)}
size="sm"
variant="outline"
>
Next
</Button>
</Stepper.NextTrigger>
</div>
)}
</Stepper.Context>
</Stepper.Root>
)
}Sizes
The stepper comes in two sizes: sm and lg. The default size is lg.
import type {ReactElement} from "react"
import {Stepper} from "@qualcomm-ui/react/stepper"
const items = [
{content: "Contact details", title: "Step 1", value: "step-1"},
{content: "Payment info", title: "Step 2", value: "step-2"},
{content: "Confirmation", title: "Step 3", value: "step-3"},
]
const sizes = ["sm", "lg"] as const
export function StepperSizesDemo(): ReactElement {
return (
<div className="flex w-full flex-col gap-16">
{sizes.map((size) => (
<Stepper.Root key={size} count={items.length} size={size}>
<Stepper.List>
{items.map((item, index) => (
<Stepper.Item key={item.value} index={index}>
<Stepper.Trigger>
<Stepper.Indicator>{index + 1}</Stepper.Indicator>
<Stepper.Label>{item.title}</Stepper.Label>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>
))}
</Stepper.List>
{items.map((item, index) => (
<Stepper.Content key={item.value} index={index}>
{item.content}
</Stepper.Content>
))}
</Stepper.Root>
))}
</div>
)
}Validation Examples
Non-linear Form
Combine linear={false} with per-step validation to let users complete steps in any order. Use the invalid and completed props to track each step's status, and Stepper.Context to conditionally render a submit action once all steps pass.
import {type ReactElement, useState} from "react"
import {type} from "arktype"
import {ChevronLeft, ChevronRight} from "lucide-react"
import {Controller, useForm} from "react-hook-form"
import {Button} from "@qualcomm-ui/react/button"
import {Stepper} from "@qualcomm-ui/react/stepper"
import {TextInput} from "@qualcomm-ui/react/text-input"
const items = [
{
label: "Age range",
name: "age" as const,
placeholder: "25-34",
title: "Demographics",
},
{
label: "Preferred contact method",
name: "contact" as const,
placeholder: "Email",
title: "Preferences",
},
{
label: "Comments",
name: "comments" as const,
placeholder: "Tell us what you think",
title: "Feedback",
},
]
const schema = type({
age: "string > 0",
comments: "string > 0",
contact: "string > 0",
})
type FormData = typeof schema.infer
export function StepperNonlinearFormDemo(): ReactElement {
const [completed, setCompleted] = useState<Record<number, boolean>>({})
const {clearErrors, control, getValues, setError} = useForm<FormData>({
defaultValues: {age: "", comments: "", contact: ""},
mode: "onChange",
reValidateMode: "onChange",
})
const [invalid, setInvalid] = useState<Record<string, boolean>>({})
function saveStep(index: number) {
const field = items[index]?.name
if (!field) {
console.debug("no field")
return
}
const result = schema(getValues())
if (result instanceof type.errors) {
const fieldError = result.find((e) => e.path?.[0] === field)
if (fieldError) {
setError(field, {message: fieldError.message})
setInvalid((prev) => ({...prev, [index]: true}))
setCompleted((prev) => ({...prev, [index]: false}))
return
}
}
clearErrors(field)
setInvalid((prev) => ({...prev, [index]: false}))
setCompleted((prev) => ({...prev, [index]: true}))
}
const allCompleted = items.every((_, i) => completed[i])
return (
<Stepper.Root
completed={completed}
count={items.length}
invalid={invalid}
linear={false}
>
<Stepper.List>
{items.map((item, index) => (
<Stepper.Item key={item.name} index={index}>
<Stepper.Trigger>
<Stepper.Indicator>{index + 1}</Stepper.Indicator>
<Stepper.Label>{item.title}</Stepper.Label>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>
))}
</Stepper.List>
{items.map((item, index) => (
<Stepper.Content key={item.name} index={index}>
<div className="flex">
<Controller
control={control}
name={item.name}
render={({
field: {onChange, ref, ...fieldProps},
fieldState: {error},
}) => (
<TextInput
className="max-w-56"
errorText={error?.message}
inputProps={{ref}}
invalid={!!error}
label={item.label}
onValueChange={onChange}
placeholder={item.placeholder}
required
{...fieldProps}
/>
)}
/>
</div>
</Stepper.Content>
))}
<Stepper.CompletedContent>
Survey submitted. Thank you for your feedback!
</Stepper.CompletedContent>
<Stepper.Context>
{(api) => (
<div className="mt-6 flex justify-between">
<Stepper.PrevTrigger>
<Button
onClick={() => saveStep(api.step)}
size="sm"
startIcon={ChevronLeft}
variant="outline"
>
Back
</Button>
</Stepper.PrevTrigger>
{allCompleted ? (
<Button
endIcon={ChevronRight}
onClick={() => {
saveStep(api.step)
api.goToNextStep()
}}
size="sm"
>
Submit
</Button>
) : (
<Stepper.NextTrigger>
<Button
disabled={
!api.hasNextStep ||
(api.step === items.length - 1 && !allCompleted)
}
endIcon={ChevronRight}
onClick={() => {
saveStep(api.step)
}}
size="sm"
>
Next
</Button>
</Stepper.NextTrigger>
)}
</div>
)}
</Stepper.Context>
</Stepper.Root>
)
}Skippable Steps
Use isStepSkippable to mark optional steps that can be bypassed during navigation.
import type {ReactElement} from "react"
import {type} from "arktype"
import {ChevronLeft, ChevronRight} from "lucide-react"
import {Controller, useForm} from "react-hook-form"
import {Button} from "@qualcomm-ui/react/button"
import {Stepper} from "@qualcomm-ui/react/stepper"
import {TextInput} from "@qualcomm-ui/react/text-input"
const PROMO_STEP = 1
const steps = [
{title: "Shipping", value: "shipping"},
{title: "Promo Code", value: "promo"},
{title: "Payment", value: "payment"},
]
const shippingSchema = type({address: "string > 0"})
const paymentSchema = type({card: "string > 0"})
type FormData = typeof shippingSchema.infer & {card: string; promo: string}
const stepSchemas: Record<number, (data: FormData) => type.errors | undefined> =
{
0: (data) => {
const result = shippingSchema(data)
return result instanceof type.errors ? result : undefined
},
2: (data) => {
const result = paymentSchema(data)
return result instanceof type.errors ? result : undefined
},
}
export function StepperSkippableStepsDemo(): ReactElement {
const {clearErrors, control, getValues, setError} = useForm<FormData>({
defaultValues: {address: "", card: "", promo: ""},
mode: "onChange",
})
function validateStep(index: number): boolean {
const validate = stepSchemas[index]
if (!validate) {
return true
}
return !validate(getValues())
}
function computeStepErrors(index: number) {
clearErrors()
const validate = stepSchemas[index]
if (!validate) {
return
}
const errors = validate(getValues())
if (!errors) {
return
}
for (const error of errors) {
const field = error.path?.[0] as keyof FormData | undefined
if (field) {
setError(field, {message: error.message})
}
}
}
return (
<Stepper.Root
canGoToStep={({current, target}) => {
if (target <= current) {
// default to the built-in validation logic
return undefined
}
return validateStep(current)
}}
count={steps.length}
// this will bypass canGoToStep for PROMO_STEP only
isStepSkippable={(index) => index === PROMO_STEP}
onStepInvalid={({step: invalidStep}) => computeStepErrors(invalidStep)}
>
<Stepper.List>
{steps.map((s, index) => (
<Stepper.Item key={s.value} index={index}>
<Stepper.Trigger>
<Stepper.Indicator>{index + 1}</Stepper.Indicator>
<Stepper.Label>
{s.title}
{index === PROMO_STEP && (
<Stepper.Hint>(optional)</Stepper.Hint>
)}
</Stepper.Label>
</Stepper.Trigger>
<Stepper.Separator />
</Stepper.Item>
))}
</Stepper.List>
<Stepper.Content index={0}>
<Controller
control={control}
name="address"
render={({field: {onChange, ...fieldProps}, fieldState: {error}}) => (
<TextInput
className="max-w-56"
errorText={error?.message}
invalid={!!error}
label="Shipping address"
onValueChange={onChange}
placeholder="123 Main St"
required
{...fieldProps}
/>
)}
/>
</Stepper.Content>
<Stepper.Content index={1}>
<Controller
control={control}
name="promo"
render={({field: {onChange, ...fieldProps}}) => (
<TextInput
className="max-w-56"
label="Promo code"
onValueChange={onChange}
placeholder="SAVE20"
{...fieldProps}
/>
)}
/>
</Stepper.Content>
<Stepper.Content index={2}>
<Controller
control={control}
name="card"
render={({field: {onChange, ...fieldProps}, fieldState: {error}}) => (
<TextInput
className="max-w-56"
errorText={error?.message}
invalid={!!error}
label="Card number"
onValueChange={onChange}
placeholder="4242 4242 4242 4242"
required
{...fieldProps}
/>
)}
/>
</Stepper.Content>
<Stepper.CompletedContent>
Order confirmed. Thank you for your purchase!
</Stepper.CompletedContent>
<div className="mt-6 flex justify-between">
<Stepper.PrevTrigger>
<Button size="sm" startIcon={ChevronLeft} variant="outline">
Back
</Button>
</Stepper.PrevTrigger>
<Stepper.NextTrigger>
<Button endIcon={ChevronRight} size="sm" variant="outline">
Next
</Button>
</Stepper.NextTrigger>
</div>
</Stepper.Root>
)
}Explorer
Component Anatomy
Hover to highlight, click to view API
API
<Stepper.Root>
| Prop | Type | Default |
|---|---|---|
The total number of steps | number | |
Whether navigation to a step should be allowed. Receives the current step,
target step, and whether the target has been previously visited. Return false to block navigation, true to allow it, or undefined
to defer to the built-in navigation rules. | (details: { | |
Record< | ||
The initial value of the stepper when rendered.
Use when you don't need to control the value of the stepper. | number | |
The document's text/writing direction. | 'ltr' | 'rtl' | 'ltr' |
A root node to correctly resolve document in custom environments. i.e.,
Iframes, Electron. | () => | |
A map of step indices to their invalid status. Note that this property is
purely visual and does not impact navigation logic. | Record< | |
Whether a step can be skipped during navigation in linear mode.
Skippable steps are bypassed when using next/prev. | ( | () => false |
If true, the stepper requires the user to complete the steps in order. | boolean | true |
Callback to be called when the value changes | ( | |
Called when navigation is blocked due to an invalid step. | (details: { | |
The orientation of the stepper | | 'horizontal' | "horizontal" |
A map of step indices to their pending status. | Record< | |
Allows you to replace the component's HTML element with a different tag or component. Learn more | | ReactElement | |
The size of the stepper and its elements. | 'sm' | 'lg' | 'lg' |
The controlled value of the stepper | number |
number(details: {
current: number
target: number
visited: boolean
}) => boolean
Return
false to block navigation, true to allow it, or undefined
to defer to the built-in navigation rules.Record<
number,
boolean
>
number'ltr' | 'rtl'
() =>
| Node
| ShadowRoot
| Document
Record<
number,
boolean
>
(
index: number,
) => boolean
booleantrue, the stepper requires the user to complete the steps in order.(
step: number,
) => void
(details: {
action: 'set' | 'next'
step: number
targetStep?: number
}) => void
| 'horizontal'
| 'horizontal-inline'
| 'horizontal-bottom-start'
| 'vertical'
| 'vertical-inline'
Record<
number,
boolean
>
| ReactElement
| ((
props: object,
) => ReactElement)
'sm' | 'lg'
number| Attribute / Property | Value |
|---|---|
className | 'qui-stepper__root' |
data-orientation | | 'horizontal' |
data-part | 'root' |
data-size | 'sm' | 'lg' |
className'qui-stepper__root'data-orientation| 'horizontal'
| 'horizontal-inline'
| 'horizontal-bottom-start'
| 'vertical'
| 'vertical-inline'
data-part'root'data-size'sm' | 'lg'
<Stepper.List>
<div> element by default.| Prop | Type |
|---|---|
id attribute. If
omitted, a unique identifier will be automatically generated for accessibility. | string |
Allows you to replace the component's HTML element with a different tag or component. Learn more | | ReactElement |
string| ReactElement
| ((
props: object,
) => ReactElement)
| Attribute / Property | Value |
|---|---|
className | 'qui-stepper__list' |
data-orientation | | 'horizontal' |
data-part | 'list' |
data-size | 'sm' | 'lg' |
className'qui-stepper__list'data-orientation| 'horizontal'
| 'horizontal-inline'
| 'horizontal-bottom-start'
| 'vertical'
| 'vertical-inline'
data-part'list'data-size'sm' | 'lg'
<Stepper.Item>
<div> element by default.| Prop | Type |
|---|---|
The index of the step | number |
Allows you to replace the component's HTML element with a different tag or component. Learn more | | ReactElement |
number| ReactElement
| ((
props: object,
) => ReactElement)
| Attribute / Property | Value |
|---|---|
className | 'qui-stepper__item' |
data-current | |
data-first | |
data-last | |
data-orientation | | 'horizontal' |
data-part | 'item' |
data-previous | |
data-size | 'sm' | 'lg' |
data-skippable |
className'qui-stepper__item'data-currentdata-firstdata-lastdata-orientation| 'horizontal'
| 'horizontal-inline'
| 'horizontal-bottom-start'
| 'vertical'
| 'vertical-inline'
data-part'item'data-previousdata-size'sm' | 'lg'
data-skippable<Stepper.Trigger>
<button> element by default.| Prop | Type |
|---|---|
id attribute. If
omitted, a unique identifier will be automatically generated for accessibility. | string |
Allows you to replace the component's HTML element with a different tag or component. Learn more | | ReactElement |
string| ReactElement
| ((
props: object,
) => ReactElement)
| Attribute / Property | Value |
|---|---|
className | 'qui-stepper__trigger' |
data-complete | |
data-current | |
data-incomplete | |
data-invalid | |
data-last | |
data-orientation | | 'horizontal' |
data-part | 'trigger' |
data-pending | |
data-size | 'sm' | 'lg' |
data-state | | 'open' |
tabIndex | -1 | 0 |
className'qui-stepper__trigger'data-completedata-currentdata-incompletedata-invaliddata-lastdata-orientation| 'horizontal'
| 'horizontal-inline'
| 'horizontal-bottom-start'
| 'vertical'
| 'vertical-inline'
data-part'trigger'data-pendingdata-size'sm' | 'lg'
data-state| 'open'
| 'closed'
tabIndex-1 | 0
<Stepper.Indicator>
<div> element by default.By default, displays the step number or icon. When a step is completed, it shows a checkmark icon. When a step is invalid, it shows an error icon.
Rendering order:
- errorIcon (when invalid)
- children (current step)
- completedIcon (when complete)
- children (not current step)
| Prop | Type | Default |
|---|---|---|
Icon to display when the step is completed. | | LucideIcon | Check |
Icon to display when the step is in an error state. | | LucideIcon | StepperIndicatorAlert |
Allows you to replace the component's HTML element with a different tag or component. Learn more | | ReactElement |
| LucideIcon
| ReactNode
| LucideIcon
| ReactNode
| ReactElement
| ((
props: object,
) => ReactElement)
| Attribute / Property | Value |
|---|---|
className | 'qui-stepper__indicator' |
data-complete | |
data-current | |
data-incomplete | |
data-orientation | | 'horizontal' |
data-part | 'indicator' |
data-size | 'sm' | 'lg' |
style |
className'qui-stepper__indicator'data-completedata-currentdata-incompletedata-orientation| 'horizontal'
| 'horizontal-inline'
| 'horizontal-bottom-start'
| 'vertical'
| 'vertical-inline'
data-part'indicator'data-size'sm' | 'lg'
style<Stepper.Separator>
<div> element by default.| Prop | Type |
|---|---|
Allows you to replace the component's HTML element with a different tag or component. Learn more | | ReactElement |
| ReactElement
| ((
props: object,
) => ReactElement)
| Attribute / Property | Value |
|---|---|
className | 'qui-stepper__separator' |
data-complete | |
data-incomplete | |
data-orientation | | 'horizontal' |
data-part | 'separator' |
data-size | 'sm' | 'lg' |
style |
className'qui-stepper__separator'data-completedata-incompletedata-orientation| 'horizontal'
| 'horizontal-inline'
| 'horizontal-bottom-start'
| 'vertical'
| 'vertical-inline'
data-part'separator'data-size'sm' | 'lg'
style<Stepper.CompletedContent>
Content displayed when all steps are completed.
<div> element by default.| Prop | Type |
|---|---|
Allows you to replace the component's HTML element with a different tag or component. Learn more | | ReactElement |
| ReactElement
| ((
props: object,
) => ReactElement)
| Attribute / Property | Value |
|---|---|
className | 'qui-stepper__completed-content' |
data-part | 'completed-content' |
data-size | 'sm' | 'lg' |
hidden | boolean |
className'qui-stepper__completed-content'data-part'completed-content'data-size'sm' | 'lg'
hiddenboolean<Stepper.Content>
<div> element by default.| Prop | Type |
|---|---|
The index of the step | number |
id attribute. If
omitted, a unique identifier will be automatically generated for accessibility. | string |
Allows you to replace the component's HTML element with a different tag or component. Learn more | | ReactElement |
numberstring| ReactElement
| ((
props: object,
) => ReactElement)
| Attribute / Property | Value |
|---|---|
className | 'qui-stepper__content' |
data-orientation | | 'horizontal' |
data-part | 'content' |
data-size | 'sm' | 'lg' |
data-state | | 'open' |
hidden | boolean |
tabIndex | 0 |
className'qui-stepper__content'data-orientation| 'horizontal'
| 'horizontal-inline'
| 'horizontal-bottom-start'
| 'vertical'
| 'vertical-inline'
data-part'content'data-size'sm' | 'lg'
data-state| 'open'
| 'closed'
hiddenbooleantabIndex0<Stepper.Label>
<span> element by default.| Prop | Type |
|---|---|
Allows you to replace the component's HTML element with a different tag or component. Learn more | | ReactElement |
| ReactElement
| ((
props: object,
) => ReactElement)
| Attribute / Property | Value |
|---|---|
className | 'qui-stepper__label' |
data-complete | |
data-current | |
data-incomplete | |
data-orientation | | 'horizontal' |
data-part | 'label' |
data-size | 'sm' | 'lg' |
className'qui-stepper__label'data-completedata-currentdata-incompletedata-orientation| 'horizontal'
| 'horizontal-inline'
| 'horizontal-bottom-start'
| 'vertical'
| 'vertical-inline'
data-part'label'data-size'sm' | 'lg'
<Stepper.Hint>
<span> element by default.| Prop | Type |
|---|---|
Allows you to replace the component's HTML element with a different tag or component. Learn more | | ReactElement |
| ReactElement
| ((
props: object,
) => ReactElement)
| Attribute / Property | Value |
|---|---|
className | 'qui-stepper__hint' |
data-complete | |
data-current | |
data-incomplete | |
data-orientation | | 'horizontal' |
data-part | 'hint' |
data-size | 'sm' | 'lg' |
className'qui-stepper__hint'data-completedata-currentdata-incompletedata-orientation| 'horizontal'
| 'horizontal-inline'
| 'horizontal-bottom-start'
| 'vertical'
| 'vertical-inline'
data-part'hint'data-size'sm' | 'lg'
<Stepper.NextTrigger>
<button> element by default.| Prop | Type |
|---|---|
Element | ((props: {data-disabled: ''; data-part: 'next-trigger'; dir: 'ltr' | 'rtl'; disabled: boolean; onClick: MouseEventHandler; scope: 'stepper'; type: 'button';}) => Element) |
Element | ((props: {data-disabled: ''; data-part: 'next-trigger'; dir: 'ltr' | 'rtl'; disabled: boolean; onClick: MouseEventHandler; scope: 'stepper'; type: 'button';}) => Element)
| Attribute / Property | Value |
|---|---|
className | 'qui-stepper__next-trigger' |
data-disabled | |
data-part | 'next-trigger' |
className'qui-stepper__next-trigger'data-disableddata-part'next-trigger'<Stepper.PrevTrigger>
<button> element by default.| Prop | Type |
|---|---|
Element | ((props: {data-disabled: ''; data-part: 'prev-trigger'; dir: 'ltr' | 'rtl'; disabled: boolean; onClick: MouseEventHandler; scope: 'stepper'; type: 'button';}) => Element) |
Element | ((props: {data-disabled: ''; data-part: 'prev-trigger'; dir: 'ltr' | 'rtl'; disabled: boolean; onClick: MouseEventHandler; scope: 'stepper'; type: 'button';}) => Element)
| Attribute / Property | Value |
|---|---|
className | 'qui-stepper__prev-trigger' |
data-disabled | |
data-part | 'prev-trigger' |
className'qui-stepper__prev-trigger'data-disableddata-part'prev-trigger'<Stepper.Context>
| Prop | Type |
|---|---|
Render Prop
that provides the current StepperApi context. |
StepperApi
| Prop | Type |
|---|---|
The total number of steps. | number |
Returns the resolved state for a step at the given index. | (props: { |
Function to go to the next step. | () => void |
Function to go to the previous step. | () => void |
Whether the stepper has a next step. | boolean |
Whether the stepper has a previous step. | boolean |
Check if a specific step can be skipped | ( |
Function to go to reset the stepper. | () => void |
Function to set the value of the stepper. | ( |
The value of the stepper. | number |
number(props: {
index: number
render?:
| Element
| ((
props: Props,
) => Element)
}) => {
completed: boolean
contentId: string
current: boolean
first: boolean
incomplete: boolean
index: number
invalid: boolean
last: boolean
pending?: boolean
previous?: boolean
skippable: boolean
triggerId: string
visited: boolean
}
() => void
() => void
booleanboolean(
index: number,
) => boolean
() => void
(
step: number,
) => void
number
<div>element by default.