component — navigation
Stepper
Horizontal progress indicator for multi-step flows, drawn in ink: the active step is the one solid bullet, done steps an ink ring, upcoming ones a hairline. Admin (labelled) and kiosk (bullet-only) variants.
- navigation
- progress
- wizard
- steps
- stepper
04 examples
Admin
Labelled bullets joined by rules. The active step is the one solid bullet; done steps are an ink ring with an ink rule after them, upcoming ones a hairline.
- 1account
- 2details
- 3billing
- 4review
Kiosk
Compact, bullet-only variant. Completed steps show a checkmark.
- ✓
- ✓
- 3
- 4
- 5
First step active
- 1plan
- 2build
- 3ship
All complete
- 1plan
- 2build
- 3ship
source
stepper.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
import { cn } from "@/lib/utils"
/**
* Stepper — quebi design system
*
* A horizontal progress indicator for multi-step flows. Progress is state, so
* the step you are on and the way behind it are drawn in `signal`.
*
* Admin (variant="admin", default):
* - row of 32px bullets + labels, joined by 1px rules
* - states: upcoming (hairline ring, subtle number), done (`fg-muted`
* ring, ink number), active (solid `signal` fill)
* - the rule following a done step is `signal`; the rest are hairlines
*
* Kiosk (variant="kiosk"):
* - 28px bullets, no labels, short connector rules
* - done shows a checkmark; active shows the step number
*
* Exactly one bullet is filled — the one the reader is on — so the eye finds it
* without a halo; its `signal` means "here", nothing decorative. Done and upcoming differ by the weight of their ring
* and their ink, the way the rest of the system tells strong from quiet.
*/
export type StepStatus = "done" | "active" | "upcoming"
export interface StepItem {
id: string
/** Required for admin; ignored by the kiosk variant. */
label?: string
status: StepStatus
}
export interface StepperProps {
steps: StepItem[]
/** Admin (32px + label) or kiosk (28px bullet-only). Defaults to admin. */
variant?: "admin" | "kiosk"
/** @deprecated Draws nothing: the design has no shadows in the page flow.
* Still accepted so existing callers compile. */
glow?: boolean
className?: string
"aria-label"?: string
}
export function Stepper({
steps,
variant = "admin",
className,
"aria-label": ariaLabel = "Progress",
}: StepperProps) {
return variant === "kiosk" ? (
<KioskStepper steps={steps} className={className} ariaLabel={ariaLabel} />
) : (
<AdminStepper steps={steps} className={className} ariaLabel={ariaLabel} />
)
}
/** Shared by both bullet sizes so the two variants cannot drift. */
const BULLET_STATE: Record<StepStatus, string> = {
done: "border-quebi-fg-muted bg-transparent text-quebi-fg",
active: "border-quebi-signal bg-quebi-signal text-quebi-on-signal",
upcoming: "border-quebi-hairline bg-transparent text-quebi-fg-subtle",
}
/** The rule after a step: `signal` once the step is done, a hairline before. */
function connectorFor(status: StepStatus) {
return status === "done" ? "bg-quebi-signal" : "bg-quebi-hairline"
}
/* ----------------------------------------------------------------------------
* Admin stepper
* ---------------------------------------------------------------------------- */
function AdminStepper({
steps,
className,
ariaLabel,
}: {
steps: StepItem[]
className?: string
ariaLabel: string
}) {
return (
<ol
aria-label={ariaLabel}
className={cn("flex list-none items-center gap-0 p-0 m-0", className)}
>
{steps.map((step, i) => {
const isLast = i === steps.length - 1
return (
<li key={step.id} className="flex flex-1 items-center last:flex-none">
<div className="flex items-center gap-2.5">
<AdminBullet index={i + 1} status={step.status} />
{step.label ? (
<span
className={cn(
"text-quebi-body-s transition-colors duration-150",
step.status === "active" && "font-medium",
step.status === "active"
? "text-quebi-fg"
: step.status === "done"
? "text-quebi-fg-muted"
: "text-quebi-fg-subtle",
)}
>
{step.label}
</span>
) : null}
</div>
{isLast ? null : (
<span
aria-hidden="true"
className={cn(
"mx-3.5 h-px flex-1 transition-colors duration-150",
connectorFor(step.status),
)}
/>
)}
</li>
)
})}
</ol>
)
}
function AdminBullet({
index,
status,
}: {
index: number
status: StepStatus
}) {
const base =
"inline-flex size-8 items-center justify-center rounded-full border font-mono text-xs tabular-nums transition-colors duration-150"
return (
<span
aria-current={status === "active" ? "step" : undefined}
className={cn(base, BULLET_STATE[status])}
>
{index}
</span>
)
}
/* ----------------------------------------------------------------------------
* Kiosk stepper
* ---------------------------------------------------------------------------- */
function KioskStepper({
steps,
className,
ariaLabel,
}: {
steps: StepItem[]
className?: string
ariaLabel: string
}) {
return (
<ol
aria-label={ariaLabel}
className={cn("flex list-none items-center gap-2.5 p-0 m-0", className)}
>
{steps.map((step, i) => {
const isLast = i === steps.length - 1
return (
<li key={step.id} className="flex items-center gap-2.5">
<KioskBullet index={i + 1} status={step.status} />
{isLast ? null : (
<span
aria-hidden="true"
className={cn(
"h-px w-7 flex-none transition-colors duration-150",
connectorFor(step.status),
)}
/>
)}
</li>
)
})}
</ol>
)
}
function KioskBullet({
index,
status,
}: {
index: number
status: StepStatus
}) {
const base =
"inline-flex size-7 items-center justify-center rounded-full border font-mono text-xs tabular-nums transition-colors duration-150"
return (
<span
aria-current={status === "active" ? "step" : undefined}
className={cn(base, BULLET_STATE[status])}
>
{status === "done" ? "✓" : index}
</span>
)
}