Stepper
Numbered steps joined by a line that fills as you move forward. Finished steps trade their number for a check and can be clicked to go back.
- 2WorkspaceName and team size, current step
- 3InviteBring your team
- 4DoneStart building
"use client";
import { useState } from "react";
import { Button } from "@/components/motion/button";
import { Stepper } from "@/components/motion/stepper";
const STEPS = [
{ label: "Account", description: "Email and password" },
{ label: "Workspace", description: "Name and team size" },
{ label: "Invite", description: "Bring your team" },
{ label: "Done", description: "Start building" },
];
export function StepperPreview() {
const [step, setStep] = useState(1);
return (
<div className="flex w-full max-w-lg flex-col items-center gap-8">
<Stepper steps={STEPS} current={step} onStepClick={setStep} />
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setStep((s) => Math.max(0, s - 1))} disabled={step === 0}>
Back
</Button>
<Button size="sm" onClick={() => setStep((s) => Math.min(STEPS.length - 1, s + 1))} disabled={step === STEPS.length - 1}>
Next
</Button>
</div>
</div>
);
}
"use client";
// easeui.dev/components/motion/stepper
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export interface StepperStep {
label: string;
/** A short line under the label. */
description?: string;
}
export interface StepperProps {
steps: StepperStep[];
/** The step in progress, counting from 0. Steps before it show as done. */
current: number;
/** Lets people jump back to a finished step. Leave unset to make the steps display only. */
onStepClick?: (index: number) => void;
className?: string;
}
/**
* Numbered steps joined by a line that fills as you move forward. Finished steps trade their
* number for a check, and the line between two steps fills from the left.
*/
export function Stepper({ steps, current, onStepClick, className }: StepperProps) {
return (
<ol className={cn("flex w-full items-start", className)}>
{steps.map((step, index) => {
const done = index < current;
const active = index === current;
const clickable = Boolean(onStepClick) && done;
const Tag = clickable ? "button" : "div";
return (
<li key={step.label} className={cn("relative flex min-w-0 flex-col items-center", index < steps.length - 1 ? "flex-1" : "")}>
{index < steps.length - 1 ? (
// The line to the next step. It sits behind the circles and fills from the left.
<span aria-hidden="true" className="absolute left-[calc(50%+18px)] right-[calc(-50%+18px)] top-4 h-0.5 overflow-hidden rounded-full bg-foreground/10">
<span
className="block h-full origin-left rounded-full bg-accent transition-transform duration-300 ease-out motion-reduce:transition-none"
style={{ transform: `scaleX(${done ? 1 : 0})` }}
/>
</span>
) : null}
<Tag
{...(clickable ? { type: "button" as const, onClick: () => onStepClick?.(index) } : {})}
aria-current={active ? "step" : undefined}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg px-1 text-center outline-none",
clickable && "cursor-pointer focus-visible:ring-2 focus-visible:ring-foreground/40",
)}
>
{/* Number and check share one spot and crossfade, so the circle never changes size. */}
<span
className={cn(
"relative grid h-8 w-8 place-items-center rounded-full text-sm font-medium tabular-nums transition-[background-color,color,box-shadow] duration-200 ease-out",
done && "bg-accent text-accent-foreground",
active && "bg-background text-foreground shadow-[0_0_0_2px_var(--accent)]",
!done && !active && "bg-background text-muted-foreground shadow-[0_0_0_1px_var(--border-strong)]",
)}
>
<span className={cn("col-start-1 row-start-1 transition-[opacity,scale] duration-200 ease-out", done ? "scale-50 opacity-0" : "scale-100 opacity-100")}>
{index + 1}
</span>
<Check
aria-hidden="true"
strokeWidth={3}
className={cn("col-start-1 row-start-1 h-4 w-4 transition-[opacity,scale] duration-200 ease-out", done ? "scale-100 opacity-100" : "scale-50 opacity-0")}
/>
</span>
<span className="flex max-w-32 flex-col gap-0.5">
<span className={cn("truncate text-sm font-medium transition-colors duration-200", active || done ? "text-foreground" : "text-muted-foreground")}>
{step.label}
</span>
{step.description ? <span className="hidden truncate text-xs text-muted-foreground sm:block">{step.description}</span> : null}
</span>
<span className="sr-only">{done ? ", done" : active ? ", current step" : ""}</span>
</Tag>
</li>
);
})}
</ol>
);
}
Installation
$ bunx --bun shadcn add @easeui/stepper
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react motion tailwind-merge - 3
Add the source files
components/motion/stepper.tsx "use client"; // easeui.dev/components/motion/stepper import { Check } from "lucide-react"; import { cn } from "@/lib/utils"; export interface StepperStep { label: string; /** A short line under the label. */ description?: string; } export interface StepperProps { steps: StepperStep[]; /** The step in progress, counting from 0. Steps before it show as done. */ current: number; /** Lets people jump back to a finished step. Leave unset to make the steps display only. */ onStepClick?: (index: number) => void; className?: string; } /** * Numbered steps joined by a line that fills as you move forward. Finished steps trade their * number for a check, and the line between two steps fills from the left. */ export function Stepper({ steps, current, onStepClick, className }: StepperProps) { return ( <ol className={cn("flex w-full items-start", className)}> {steps.map((step, index) => { const done = index < current; const active = index === current; const clickable = Boolean(onStepClick) && done; const Tag = clickable ? "button" : "div"; return ( <li key={step.label} className={cn("relative flex min-w-0 flex-col items-center", index < steps.length - 1 ? "flex-1" : "")}> {index < steps.length - 1 ? ( // The line to the next step. It sits behind the circles and fills from the left. <span aria-hidden="true" className="absolute left-[calc(50%+18px)] right-[calc(-50%+18px)] top-4 h-0.5 overflow-hidden rounded-full bg-foreground/10"> <span className="block h-full origin-left rounded-full bg-accent transition-transform duration-300 ease-out motion-reduce:transition-none" style={{ transform: `scaleX(${done ? 1 : 0})` }} /> </span> ) : null} <Tag {...(clickable ? { type: "button" as const, onClick: () => onStepClick?.(index) } : {})} aria-current={active ? "step" : undefined} className={cn( "relative flex flex-col items-center gap-2 rounded-lg px-1 text-center outline-none", clickable && "cursor-pointer focus-visible:ring-2 focus-visible:ring-foreground/40", )} > {/* Number and check share one spot and crossfade, so the circle never changes size. */} <span className={cn( "relative grid h-8 w-8 place-items-center rounded-full text-sm font-medium tabular-nums transition-[background-color,color,box-shadow] duration-200 ease-out", done && "bg-accent text-accent-foreground", active && "bg-background text-foreground shadow-[0_0_0_2px_var(--accent)]", !done && !active && "bg-background text-muted-foreground shadow-[0_0_0_1px_var(--border-strong)]", )} > <span className={cn("col-start-1 row-start-1 transition-[opacity,scale] duration-200 ease-out", done ? "scale-50 opacity-0" : "scale-100 opacity-100")}> {index + 1} </span> <Check aria-hidden="true" strokeWidth={3} className={cn("col-start-1 row-start-1 h-4 w-4 transition-[opacity,scale] duration-200 ease-out", done ? "scale-100 opacity-100" : "scale-50 opacity-0")} /> </span> <span className="flex max-w-32 flex-col gap-0.5"> <span className={cn("truncate text-sm font-medium transition-colors duration-200", active || done ? "text-foreground" : "text-muted-foreground")}> {step.label} </span> {step.description ? <span className="hidden truncate text-xs text-muted-foreground sm:block">{step.description}</span> : null} </span> <span className="sr-only">{done ? ", done" : active ? ", current step" : ""}</span> </Tag> </li> ); })} </ol> ); }lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }components/motion/button.tsx "use client"; import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react"; import { forwardRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost"; export type ButtonSize = "sm" | "md" | "lg" | "icon"; export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "children"> { /** Visual style. Default "primary". */ variant?: ButtonVariant; /** Height and padding. "icon" is a square button for a single icon. Default "md". */ size?: ButtonSize; children?: ReactNode; } // A quick press confirms the tap before the action finishes. const PRESS = { scale: 0.97 }; const NO_PRESS = { scale: 1 }; const PRESS_TRANSITION = { duration: 0.15, ease: [0.23, 1, 0.32, 1] } as const; // Hairline rings are drawn with box-shadow so they blend with any background. const VARIANT_CLASS: Record<ButtonVariant, string> = { primary: "bg-foreground text-background hover:bg-foreground/90", secondary: "bg-card text-foreground shadow-[0_0_0_1px_var(--border)] hover:bg-muted", outline: "text-foreground shadow-[0_0_0_1px_var(--border-strong)] hover:bg-foreground/5", ghost: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground", }; // Small sizes grow an invisible hit area so the tap target stays around 44px. const SIZE_CLASS: Record<ButtonSize, string> = { sm: "h-8 gap-1.5 px-3 text-xs after:absolute after:-inset-1.5", md: "h-10 gap-2 px-4 text-sm", lg: "h-12 gap-2 px-5 text-base", icon: "h-9 w-9 after:absolute after:-inset-1", }; export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button( { variant = "primary", size = "md", type = "button", className, children, ...props }, ref, ) { const reduce = useReducedMotion(); return ( <motion.button ref={ref} type={type} // Always pass a gesture so the server and client render the same attributes. whileTap={reduce ? NO_PRESS : PRESS} transition={PRESS_TRANSITION} className={cn( "group relative inline-flex shrink-0 touch-manipulation select-none items-center justify-center rounded-full font-medium outline-none will-change-transform", "transition-[background-color,color,box-shadow] duration-150 ease-out", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:pointer-events-none disabled:opacity-50", VARIANT_CLASS[variant], SIZE_CLASS[size], className, )} {...props} > {children} </motion.button> ); });
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | {} | - | - |
| current | number | - | The step in progress, counting from 0. Steps before it show as done. |
| onStepClick? | ((index: number) => void) | - | Lets people jump back to a finished step. Leave unset to make the steps display only. |
| className? | string | - | - |