Cart & checkout/component
Checkout stepper
Where the shopper is in checkout: a rail of equal ruled cells, edge to edge. Done steps carry a check and are buttons back to them; the current step is aria-current="step"; steps to come are not clickable. Never colour alone — a check, a number, and a heavy rule under the current step that slides on as the shopper moves (Motion; a fade under reduced motion). On a phone the rail shows numbers, with the step named below it.
$ npx shadcn@latest add @ecomcn/checkout-stepperOlder shadcn CLI, or want the registry pinned?
Register the namespace yourself, once per project, then run the same add command. {name} is literal — the CLI fills it in.
$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.jsonPreview — live
100%
Usage
Above each checkout stepimport { CheckoutStepper } from "@/components/ecomcn/checkout-stepper"
const steps = [
{ id: "bag", label: "Bag" },
{ id: "details", label: "Details" },
{ id: "delivery", label: "Delivery" },
{ id: "payment", label: "Payment" },
]
// Only done steps call this: the shopper can go back, never skip ahead.
<CheckoutStepper steps={steps} value={step} onValueChange={setStep} />Props
Read straight out of the source at build time, so this cannot drift from the file you install.
CheckoutStepperPropssteps: CheckoutStep[]
/** The current step's id. */
value: string
/** Called with a done step's id when the shopper goes back to it. */
onValueChange?: (id: string) => void
/** "Checkout progress", by default. */
label?: stringSource
components/ecomcn/checkout-stepper.tsx"use client"
import * as React from "react"
import { Check } from "lucide-react"
import { MotionConfig, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/*
* Where the shopper is in checkout: a rail of equal ruled cells, edge to
* edge — not floating pills.
*
* <CheckoutStepper steps={STEPS} value={step} onValueChange={setStep} />
*
* Done steps carry a check and are buttons that go back to them; the
* current one is marked `aria-current="step"`; steps still to come are not
* clickable — you can't skip ahead of what checkout needs. State is never
* colour alone: a check, a number, and the heavy rule under the current
* step, which slides to the next as the shopper moves on (Motion; a fade
* under reduced motion). On a phone the labels give way to numbers, with
* the current step named in full below the rail.
*/
export interface CheckoutStep {
id: string
label: string
}
/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */
const MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const
export interface CheckoutStepperProps extends Omit<React.HTMLAttributes<HTMLElement>, "onChange"> {
steps: CheckoutStep[]
/** The current step's id. */
value: string
/** Called with a done step's id when the shopper goes back to it. */
onValueChange?: (id: string) => void
/** "Checkout progress", by default. */
label?: string
}
export function CheckoutStepper({
steps,
value,
onValueChange,
label = "Checkout progress",
className,
...props
}: CheckoutStepperProps) {
const reduce = useReducedMotion()
const scope = React.useId()
const current = Math.max(0, steps.findIndex((s) => s.id === value))
const step = steps[current]
if (!steps.length) return null
return (
<MotionConfig reducedMotion="user">
<nav aria-label={label} data-slot="checkout-stepper" className={cn("w-full", className)} {...props}>
<ol
className="grid border-y"
style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}
>
{steps.map((s, i) => {
const state = i < current ? "done" : i === current ? "current" : "pending"
const cell = cn(
"relative flex h-12 w-full items-center gap-2.5 px-3 text-left text-sm outline-none sm:px-4",
i > 0 && "border-l"
)
const marker = (
<span
aria-hidden
className={cn(
"grid size-5 shrink-0 place-items-center text-[11px] tabular-nums",
state === "done" && "bg-foreground text-background",
state === "current" && "border border-foreground",
state === "pending" && "border border-border text-muted-foreground"
)}
>
{state === "done" ? <Check className="size-3" /> : i + 1}
</span>
)
const text = (
<span
className={cn(
"hidden truncate sm:inline",
state === "pending" ? "text-muted-foreground" : "text-foreground",
state === "current" && "font-medium"
)}
>
{s.label}
</span>
)
return (
<li key={s.id} data-state={state} className="relative min-w-0">
{state === "done" && onValueChange ? (
<button
type="button"
onClick={() => onValueChange(s.id)}
className={cn(cell, "transition-colors hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset")}
>
{marker}
{text}
<span className="sr-only">{`${s.label}, done. Go back to this step`}</span>
</button>
) : (
<span aria-current={state === "current" ? "step" : undefined} className={cell}>
{marker}
{text}
<span className="sr-only">
{state === "current" ? `${s.label}, current step` : state === "done" ? `${s.label}, done` : `${s.label}, not started`}
</span>
</span>
)}
{state === "current" ? (
<motion.span
aria-hidden
layoutId={reduce ? undefined : `${scope}-current`}
initial={reduce ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
transition={reduce ? { duration: 0.15 } : MORPH}
className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground"
/>
) : null}
</li>
)
})}
</ol>
{/* A phone shows numbers in the rail; the step is named here. */}
<p className="mt-2 text-sm sm:hidden">
<span className="text-muted-foreground tabular-nums">
Step {current + 1} of {steps.length}:
</span>{" "}
{step?.label}
</p>
</nav>
</MotionConfig>
)
}