Stepper
pnpm dlx shadcn@latest add …/r/stepper.jsonAn accessible React stepper for Next.js and Tailwind CSS: the steps of a checkout or setup, with a line that fills as you advance and checks that draw in.
import {
Stepper,
StepperIndicator,
StepperItem,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/ui/stepper"
const steps = ["Cart", "Shipping", "Payment"]
export function Checkout() {
return (
<Stepper defaultValue={2} aria-label="Checkout">
{steps.map((title, index) => (
<StepperItem key={title} step={index + 1}>
<StepperTrigger>
<StepperIndicator />
<StepperTitle>{title}</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
))}
</Stepper>
)
}"use client";
import { Button } from "@/components/ui/button";
import {
Stepper,
StepperIndicator,
StepperItem,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/ui/stepper";
import { useRef, useState } from "react";
const steps = ["Cart", "Shipping", "Payment", "Review"];
/*
* A four-step checkout. Next and Back move one step: the line fills or empties, and the step you leave gets its
* check (or loses it). Steps you've reached can be pressed to jump back; the ones ahead aren't reachable yet.
* Past Review every step is done. When Back or Next can't go further, focus moves to the other one.
*/
export default function StepperCheckout() {
const [step, setStep] = useState(2);
const back = useRef<HTMLButtonElement>(null);
const next = useRef<HTMLButtonElement>(null);
const last = steps.length + 1;
const go = (to: number) => {
setStep(to);
if (to === 1) next.current?.focus();
if (to === last) back.current?.focus();
};
return (
<div className="flex w-full max-w-xl flex-col gap-10">
<Stepper value={step} onValueChange={setStep} aria-label="Checkout">
{steps.map((title, index) => (
<StepperItem key={title} step={index + 1} disabled={index + 1 > step}>
<StepperTrigger>
<StepperIndicator />
<StepperTitle>{title}</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
))}
</Stepper>
<div className="flex justify-end gap-2">
<Button ref={back} variant="outline" size="sm" disabled={step === 1} onClick={() => go(step - 1)}>
Back
</Button>
<Button ref={next} size="sm" disabled={step === last} onClick={() => go(step + 1)}>
Next
</Button>
</div>
</div>
);
}Installation
Install the dependencies.
$ pnpm add motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { blur, duration, ease } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { motion, useReducedMotion } from "motion/react"; import { Slot } from "radix-ui"; import { type ComponentProps, createContext, type MouseEvent, useCallback, useContext, useEffect, useMemo, useState, } from "react"; /* * Stepper: progress through the steps of a task (owner's roadmap, 2026-10-16), such as a checkout or a setup. * shadcn has none, so the parts are ours, named the shadcn way: Stepper (an ordered list), StepperItem, * StepperTrigger, StepperIndicator, StepperTitle, StepperDescription and StepperSeparator. * - State: `value` is the current step, from 1; the steps before it are completed, and a value past the last * step marks them all done. Controlled or not (value / defaultValue / onValueChange), horizontal or vertical. * - Motion (the crafted part, .agents/rules/motion.md): progress travels along the line. Moving on, the step you * leave swaps its number for a check that draws itself in (Checkbox's check), the line to the next step fills * (scaleX, or scaleY when vertical, on an eased tween: duration.slow, ease-in-out, never a spring), and the next * step takes the ring as the fill arrives. Jumping several steps fills each line in turn. Going back plays it * backwards: the ring leaves, the lines empty towards the step you picked, and as the emptying reaches it, its * check erases and its number comes back through the blur. * - Fills and colours only fade; the number comes and goes through the blur; the ring grows into place as it * fades in. Nothing moves on first paint, and with reduced motion it all changes at once. * - Keyboard and screen readers: each step is a button (or, with asChild, a link) in the tab order, named by its * title; the current one has aria-current="step". It's an `ol`, so it's read as a list of so many steps. */ type StepState = "completed" | "active" | "inactive"; type Orientation = "horizontal" | "vertical"; /** One line's fill. Each step along the way waits for the line before it. */ const LEG = duration.slow; type StepperContextValue = { value: number; /** The step the last change came from, while its progress is still travelling; then the same as value */ from: number; orientation: Orientation; reduce: boolean; setValue: (step: number) => void; }; const StepperContext = createContext<StepperContextValue | null>(null); function useStepper(part: string) { const context = useContext(StepperContext); if (!context) throw new Error(`${part} must be used inside Stepper`); return context; } type StepperItemContextValue = { step: number; state: StepState; disabled: boolean; /** Seconds until this step's indicator changes: when the travelling progress reaches it */ delay: number; /** Seconds until the line after this step starts filling or emptying */ lineDelay: number; }; const StepperItemContext = createContext<StepperItemContextValue | null>(null); function useStepperItem(part: string) { const context = useContext(StepperItemContext); if (!context) throw new Error(`${part} must be used inside StepperItem`); return context; } /** A number of legs, in seconds (to the millisecond, so 3 × 0.35 is 1.05) */ const legs = (count: number) => Math.round(count * LEG * 1000) / 1000; /** When a step changes and when the line after it moves, as progress travels from `from` to `to` */ function timingOf(step: number, from: number, to: number) { if (to > from) { return { delay: step >= from && step <= to ? legs(step - from) : 0, lineDelay: step >= from && step < to ? legs(step - from) : 0, }; } if (to < from) { return { delay: step >= to && step <= from ? legs(from - step) : 0, lineDelay: step >= to && step < from ? legs(from - 1 - step) : 0, }; } return { delay: 0, lineDelay: 0 }; } export type StepperProps = Omit<ComponentProps<"ol">, "defaultValue" | "onChange"> & { /** The current step, from 1, when you control it. Past the last step, every step is done */ value?: number; /** The current step at first */ defaultValue?: number; /** Called with the step a trigger was pressed for */ onValueChange?: (value: number) => void; orientation?: Orientation; }; /** The steps of a task, in order. Give it a name with aria-label ("Checkout") */ function Stepper({ value, defaultValue = 1, onValueChange, orientation = "horizontal", className, ...props }: StepperProps) { const [uncontrolled, setUncontrolled] = useState(defaultValue); const current = value ?? uncontrolled; const reduce = useReducedMotion() ?? false; // Where the last change came from, so progress can travel from there. Once it has arrived, `from` catches up // and every delay is 0 again, so hovering a step never waits. const [shown, setShown] = useState(current); const [from, setFrom] = useState(current); if (current !== shown) { setShown(current); setFrom(shown); } useEffect(() => { if (from === current) return; const timer = setTimeout(() => setFrom(current), (Math.abs(current - from) + 1) * LEG * 1000); return () => clearTimeout(timer); }, [from, current]); const setValue = useCallback( (step: number) => { if (value === undefined) setUncontrolled(step); onValueChange?.(step); }, [value, onValueChange], ); const context = useMemo( () => ({ value: current, from: reduce ? current : from, orientation, reduce, setValue }), [current, from, orientation, reduce, setValue], ); return ( <StepperContext.Provider value={context}> <ol data-slot="stepper" data-orientation={orientation} className={cn( "flex", orientation === "horizontal" ? "w-full items-start gap-3" : "flex-col", className, )} {...props} /> </StepperContext.Provider> ); } export type StepperItemProps = ComponentProps<"li"> & { /** Its place in the task, from 1 */ step: number; /** Done (true) or not (false), whatever the current step. Left out, the steps before the current one are done */ completed?: boolean; /** Its trigger can't be pressed, for example a step that isn't reachable yet */ disabled?: boolean; }; /** One step. Holds its trigger and the separator to the next step */ function StepperItem({ step, completed, disabled = false, className, ...props }: StepperItemProps) { const { value, from, orientation } = useStepper("StepperItem"); const state: StepState = (completed ?? step < value) ? "completed" : step === value ? "active" : "inactive"; const { delay, lineDelay } = timingOf(step, from, value); const item = useMemo( () => ({ step, state, disabled, delay, lineDelay }), [step, state, disabled, delay, lineDelay], ); return ( <StepperItemContext.Provider value={item}> <li data-slot="stepper-item" data-state={state} data-disabled={disabled || undefined} className={cn( "group/step", // Horizontal: the trigger, then the line, which takes the room up to the next step orientation === "horizontal" ? "flex items-start gap-3 not-last:flex-1" : "relative not-last:pb-6", className, )} {...props} /> </StepperItemContext.Provider> ); } export type StepperTriggerProps = ComponentProps<"button"> & { /** Render the single child instead of a button, for example a link to the step's page */ asChild?: boolean; }; /** * Goes to its step. Holds the indicator, the title and the description, laid out in two columns. The current * step's trigger has aria-current="step". */ function StepperTrigger({ className, asChild = false, onClick, style, ...props }: StepperTriggerProps) { const { setValue, reduce } = useStepper("StepperTrigger"); const { step, state, disabled, delay } = useStepperItem("StepperTrigger"); const Comp = asChild ? Slot.Root : "button"; return ( <Comp data-slot="stepper-trigger" data-state={state} type={asChild ? undefined : "button"} aria-current={state === "active" ? "step" : undefined} disabled={asChild ? undefined : disabled} aria-disabled={asChild && disabled ? true : undefined} onClick={(event: MouseEvent<HTMLButtonElement>) => { onClick?.(event); if (event.defaultPrevented) return; if (disabled) { // A disabled link mustn't navigate either event.preventDefault(); return; } setValue(step); }} className={cn( // Two columns: the indicator, spanning both rows, then the title over the description "group/trigger grid cursor-pointer items-start justify-items-start gap-x-2 rounded-md text-start", "transition-opacity duration-(--duration-base) ease-out", "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", "disabled:pointer-events-none disabled:opacity-(--opacity-disabled) aria-disabled:pointer-events-none aria-disabled:opacity-(--opacity-disabled)", className, )} // A step that becomes reachable brightens as the progress arrives style={{ transitionDelay: `${reduce ? 0 : delay}s`, ...style }} {...props} /> ); } /** The same check as Checkbox, Copy Button and Loading Button */ const CHECK = "M5 12.5l4.5 4.5L19 7.5"; export type StepperIndicatorProps = ComponentProps<"span">; /** * The step's circle: its number, a check once it's done, and a ring round the current one. Pass children (an * icon) to show them instead of the number and the check. Hidden from screen readers: the title names the step. */ function StepperIndicator({ className, children, style, ...props }: StepperIndicatorProps) { const { reduce } = useStepper("StepperIndicator"); const { step, state, delay } = useStepperItem("StepperIndicator"); const done = state === "completed"; const active = state === "active"; const wait = reduce ? 0 : delay; return ( <span aria-hidden data-slot="stepper-indicator" data-state={state} className={cn( "relative row-span-2 inline-grid size-6 shrink-0 place-items-center rounded-full text-xs font-medium tabular-nums", // The fill only fades (motion.md), when the progress reaches the step "bg-muted text-muted-foreground transition-colors duration-(--duration-base) ease-out", "data-[state=active]:bg-primary data-[state=active]:text-primary-foreground", "data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground", "group-hover/trigger:data-[state=inactive]:text-foreground", "[&_svg]:pointer-events-none [&>svg]:size-3.5", className, )} style={{ transitionDelay: `${wait}s`, ...style }} {...props} > {/* The ring round the current step grows into place as it fades in */} <motion.span data-slot="stepper-ring" className="pointer-events-none absolute -inset-1 rounded-full border border-primary/30" initial={false} animate={active ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.8 }} transition={ reduce ? { duration: 0 } : { duration: duration.base, ease: active ? ease.out : ease.in, delay: wait } } /> {children ?? ( <> {/* The number lifts away through the blur as the check draws in, and comes back the same way */} <motion.span className="col-start-1 row-start-1" initial={false} // The same hidden state with reduced motion (it's instant then, so the blur never shows), so the // server's HTML always hydrates animate={done ? { opacity: 0, filter: blur.hidden } : { opacity: 1, filter: blur.shown }} transition={ reduce ? { duration: 0 } : { duration: duration.fast, ease: done ? ease.in : ease.out, delay: done ? wait : wait + duration.fast, } } > {step} </motion.span> <svg viewBox="0 0 24 24" fill="none" aria-hidden className="col-start-1 row-start-1"> {/* Opacity goes with the length, so a round cap never leaves a dot behind */} <motion.path d={CHECK} stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" initial={false} animate={{ pathLength: done ? 1 : 0, opacity: done ? 1 : 0 }} transition={ reduce ? { duration: 0 } : done ? { pathLength: { duration: duration.base, ease: ease.out, delay: wait + duration.instant, }, opacity: { duration: 0, delay: wait + duration.instant }, } : { duration: duration.fast, ease: ease.in, delay: wait } } /> </svg> </> )} </span> ); } /** The step's name, beside the indicator. Muted until the progress reaches it */ function StepperTitle({ className, style, ...props }: ComponentProps<"span">) { const { reduce } = useStepper("StepperTitle"); const { state, delay } = useStepperItem("StepperTitle"); return ( <span data-slot="stepper-title" data-state={state} className={cn( // As tall as the indicator, so the two are centred on one line "col-start-2 row-start-1 text-sm leading-6 font-medium text-muted-foreground", "transition-colors duration-(--duration-base) ease-out group-hover/trigger:text-foreground", "data-[state=active]:text-foreground data-[state=completed]:text-foreground", className, )} style={{ transitionDelay: `${reduce ? 0 : delay}s`, ...style }} {...props} /> ); } /** A line under the title */ function StepperDescription({ className, ...props }: ComponentProps<"span">) { return ( <span data-slot="stepper-description" className={cn("col-start-2 row-start-2 text-xs text-muted-foreground", className)} {...props} /> ); } /** * The line to the next step. It fills when this step is done, and empties the way it came. Hidden in the last * step, so it can be written in every item. */ function StepperSeparator({ className, ...props }: ComponentProps<"div">) { const { orientation, reduce } = useStepper("StepperSeparator"); const { state, lineDelay } = useStepperItem("StepperSeparator"); const filled = state === "completed"; const horizontal = orientation === "horizontal"; return ( <div aria-hidden data-slot="stepper-separator" data-state={filled ? "filled" : "empty"} className={cn( "overflow-hidden rounded-full bg-border group-last/step:hidden", horizontal ? // Centred on the indicators' middle line "relative mt-3 h-0.5 min-w-4 flex-1 -translate-y-1/2" : // Down the indicators' middle, with a 4px gap at each end "absolute start-3 top-7 bottom-1 w-0.5 -translate-x-1/2 rtl:translate-x-1/2", className, )} {...props} > <div data-slot="stepper-separator-fill" className={cn( "absolute inset-0 rounded-full bg-primary transition-transform duration-(--duration-slow) ease-in-out", horizontal ? "origin-left rtl:origin-right" : "origin-top", !filled && (horizontal ? "scale-x-0" : "scale-y-0"), )} style={{ transitionDelay: `${reduce ? 0 : lineDelay}s` }} /> </div> ); } export { Stepper, StepperDescription, StepperIndicator, StepperItem, StepperSeparator, StepperTitle, StepperTrigger, };Update the import paths to match your project.
Examples
Vertical
orientation="vertical", with a description under each title. Every step can be pressed here.
import {
Stepper,
StepperDescription,
StepperIndicator,
StepperItem,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/ui/stepper";
const steps = [
{ title: "Create your account", description: "Your name and a work email." },
{ title: "Invite your team", description: "Add the people you work with." },
{ title: "Connect a project", description: "Pick a repository to start from." },
{ title: "Go live", description: "Publish when you're ready." },
];
/*
* Down the side, with a line under each title. Press any step: the lines fill or empty one after another on the
* way there.
*/
export default function StepperVertical() {
return (
<Stepper defaultValue={2} orientation="vertical" aria-label="Setup" className="w-full max-w-xs">
{steps.map(({ title, description }, index) => (
<StepperItem key={title} step={index + 1}>
<StepperTrigger>
<StepperIndicator />
<StepperTitle>{title}</StepperTitle>
<StepperDescription>{description}</StepperDescription>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
))}
</Stepper>
);
}API reference
Stepper
An ordered list of the steps. Give it a name with aria-label ("Checkout"). Also accepts every native ol prop.
| Prop | Type | Default | Description |
|---|---|---|---|
value? | number | – | The current step, from 1, when you control it. Past the last step, every step is done. |
defaultValue? | number | 1 | The current step at first. |
onValueChange? | (value: number) => void | – | Called with the step a trigger was pressed for. |
orientation? | "horizontal" | "vertical" | "horizontal" | A row with the lines between the steps, or a column with the lines down the side. |
StepperItem
One step (an li), holding its trigger and its separator. Also accepts every native li prop.
| Prop | Type | Default | Description |
|---|---|---|---|
step | number | – | Its place in the task, from 1. |
completed? | boolean | – | Done or not, whatever the current step. Left out, the steps before the current one are done. |
disabled? | boolean | false | Its trigger can't be pressed, for example a step that isn't reachable yet. |
StepperTrigger
A button that goes to its step, named by its title; the current one has aria-current="step". It lays the indicator beside the title and the description. Also accepts every native button prop.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild? | boolean | false | Render the single child instead, for example a link to the step's page. |
StepperIndicator
The circle: the step's number, a check that draws in once it's done, and a ring round the current step. Hidden from screen readers. Pass children (an icon) to show them instead of the number and the check.
| Prop | Type | Default | Description |
|---|
StepperTitle
The step's name, muted until the progress reaches it. Also accepts every native span prop.
| Prop | Type | Default | Description |
|---|
StepperDescription
A line under the title. Also accepts every native span prop.
| Prop | Type | Default | Description |
|---|
StepperSeparator
The line to the next step: it fills once this step is done, and empties the way it came. Hidden in the last step, so it can be written in every item.
| Prop | Type | Default | Description |
|---|
Related components
An accessible React progress bar and ring for Next.js and Tailwind CSS that show how far along a task is, with an indeterminate state.
- Your name and email, as others see them.
Accessible React tabs for Next.js, with an underline or pill that slides to the active tab and panels that glide in, built on Radix Tabs.
An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.