ecomcn
All blocks

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.

See checkout stepper in a working shop — the demo store is built from every ecomcn block.Open the demo store
$ npx shadcn@latest add @ecomcn/checkout-stepper
Older 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}.json
Preview — live
100%

Usage

Above each checkout step
import { 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.

CheckoutStepperProps
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

Source

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>
  )
}