# Checkout stepper

> Checkout steps with an elastic progress line, ticks that pop in and content that slides the right way.

- Category: [Navigation](https://library.aimran.es/components/navigation)
- Built with: React + Motion
- Live editor: https://library.aimran.es/blocks/checkout-stepper
- Library: AIMRAN Library (https://library.aimran.es), free, no sign-up, by Aimran Oukham

## Where to use it

Paying is where online shops lose people, and a clear sense of how much is left helps. This checkout stepper takes someone from cart to shipping, payment and confirmation, with the main button changing to “Pay €48.90” on the right step. It also fits any multi‑step form, like a booking or a signup.

Keep it to three to five steps, more and the dots get cramped. Completed steps can be clicked to go back, future ones cannot, which avoids half‑filled orders. If the card is narrow, hide the labels and let the numbers do the work.

## Code

### React (Motion)

```tsx
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Check } from "lucide-react";

const CHECKOUT_STEPS = [
  {
    label: "Cart",
    title: "Your cart",
    body: "Aura headphones + case",
    meta: "2 items",
  },
  { label: "Shipping", title: "Delivery", body: "Calle Mayor 14, Madrid", meta: "Thursday 12" },
  { label: "Payment", title: "Payment", body: "Visa ending in 4242", meta: "No fees" },
  {
    label: "Done",
    title: "Order confirmed",
    body: "Receipt sent by email",
    meta: "#A-20931",
  },
];

interface CheckoutStepperProps {
  color?: string;
  showLabels?: boolean;
}

export default function CheckoutStepper({
  color = "#635bff",
  showLabels = true,
}: CheckoutStepperProps) {
  const [step, setStep] = useState(0);
  const [dir, setDir] = useState(1);
  const reduce = useReducedMotion();
  const last = CHECKOUT_STEPS.length - 1;
  const current = CHECKOUT_STEPS[step]!;
  const done = step === last;

  const go = (i: number) => {
    setDir(i >= step ? 1 : -1);
    setStep(i);
  };

  const primary = step === 2 ? "Pay €48.90" : done ? "New order" : "Continue";
  const slide = reduce ? 0 : 28;

  return (
    <div className="w-[22rem] max-w-full rounded-[22px] bg-white p-5 text-[#1d1d1f] shadow-[0_1px_2px_rgba(0,0,0,0.04),0_12px_40px_rgba(0,0,0,0.08)] ring-1 ring-black/[0.05] dark:bg-[#1c1c1e] dark:text-[#f5f5f7] dark:shadow-[0_12px_40px_rgba(0,0,0,0.45)] dark:ring-white/10">
      <div className="relative">
        <div
          aria-hidden
          className="absolute left-3 right-3 top-3 h-[2px] -translate-y-1/2 rounded-full bg-black/[0.06] dark:bg-white/10"
        >
          <motion.div
            className="h-full rounded-full"
            style={{ backgroundColor: color }}
            initial={false}
            animate={{ width: (step / last) * 100 + "%" }}
            transition={{ type: "spring", stiffness: 140, damping: 22 }}
          />
        </div>
        <ol className="relative flex justify-between">
          {CHECKOUT_STEPS.map((s, i) => {
            const complete = i < step || done;
            const isCurrent = i === step && !done;
            return (
              <li key={s.label} className="relative flex w-6 flex-col items-center">
                <button
                  type="button"
                  disabled={i >= step || done}
                  onClick={() => go(i)}
                  aria-label={s.label + (complete ? " (hecho)" : "")}
                  aria-current={i === step ? "step" : undefined}
                  className="relative grid h-6 w-6 place-items-center rounded-full bg-white text-[11px] font-semibold tabular-nums outline-none transition-[box-shadow,background-color,color] duration-300 focus-visible:ring-2 focus-visible:ring-offset-2 enabled:hover:scale-110 disabled:cursor-default dark:bg-[#1c1c1e] dark:focus-visible:ring-offset-[#1c1c1e]"
                  style={{
                    backgroundColor: complete ? color : undefined,
                    color: complete ? "#fff" : isCurrent ? color : "#aeaeb2",
                    boxShadow: complete
                      ? "none"
                      : isCurrent
                        ? "inset 0 0 0 2px " + color + ", 0 0 0 5px " + color + "22"
                        : "inset 0 0 0 1.5px rgba(128,128,128,0.3)",
                    ["--tw-ring-color" as string]: color,
                  }}
                >
                  <AnimatePresence mode="popLayout" initial={false}>
                    {complete ? (
                      <motion.span
                        key="check"
                        initial={{ scale: 0, rotate: -45 }}
                        animate={{ scale: 1, rotate: 0 }}
                        transition={{ type: "spring", stiffness: 520, damping: 22 }}
                        className="grid place-items-center"
                      >
                        <Check className="h-3.5 w-3.5" strokeWidth={3} />
                      </motion.span>
                    ) : (
                      <motion.span
                        key="n"
                        initial={{ opacity: 0 }}
                        animate={{ opacity: 1 }}
                        exit={{ opacity: 0 }}
                      >
                        {i + 1}
                      </motion.span>
                    )}
                  </AnimatePresence>
                </button>
                {showLabels && (
                  <span
                    className="absolute top-8 whitespace-nowrap text-[11px] font-medium transition-colors duration-300"
                    style={{ color: i <= step ? undefined : "#aeaeb2" }}
                  >
                    {s.label}
                  </span>
                )}
              </li>
            );
          })}
        </ol>
      </div>

      <div className={"relative overflow-hidden " + (showLabels ? "mt-11" : "mt-6")}>
        <AnimatePresence mode="popLayout" custom={dir} initial={false}>
          <motion.div
            key={step}
            custom={dir}
            initial={{ x: dir * slide, opacity: 0, filter: "blur(4px)" }}
            animate={{ x: 0, opacity: 1, filter: "blur(0px)" }}
            exit={{ x: dir * -slide, opacity: 0, filter: "blur(4px)" }}
            transition={{ type: "spring", stiffness: 320, damping: 32 }}
          >
            <p className="text-[20px] font-semibold tracking-[-0.02em]">{current.title}</p>
            <div className="mt-3 flex items-center justify-between gap-3 border-t border-black/[0.06] pt-3 text-[13px] dark:border-white/10">
              <span className="truncate text-[#86868b]">{current.body}</span>
              <span className="shrink-0 font-medium tabular-nums">{current.meta}</span>
            </div>
          </motion.div>
        </AnimatePresence>
      </div>

      <div className="mt-5 flex items-center gap-2">
        <AnimatePresence initial={false}>
          {step > 0 && !done && (
            <motion.button
              type="button"
              onClick={() => go(step - 1)}
              initial={{ opacity: 0, x: -6 }}
              animate={{ opacity: 1, x: 0 }}
              exit={{ opacity: 0, x: -6 }}
              whileTap={{ scale: 0.96 }}
              className="h-10 rounded-full px-4 text-[13px] font-medium text-[#86868b] outline-none transition-colors hover:bg-black/[0.05] hover:text-[#1d1d1f] focus-visible:ring-2 focus-visible:ring-black/20 dark:hover:bg-white/10 dark:hover:text-[#f5f5f7] dark:focus-visible:ring-white/30"
            >
              Back
            </motion.button>
          )}
        </AnimatePresence>
        <motion.button
          type="button"
          layout
          whileHover={{ y: -1 }}
          whileTap={{ scale: 0.97, y: 0 }}
          transition={{ type: "spring", stiffness: 500, damping: 30 }}
          onClick={() => go(done ? 0 : step + 1)}
          className="ml-auto h-10 rounded-full px-5 text-[13px] font-semibold text-white outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-[#1c1c1e]"
          style={{
            backgroundColor: color,
            boxShadow:
              "inset 0 1px 0 rgba(255,255,255,0.2), 0 1px 2px rgba(0,0,0,0.12), 0 8px 20px " +
              color +
              "40",
            ["--tw-ring-color" as string]: color,
          }}
        >
          {primary}
        </motion.button>
      </div>
    </div>
  );
}
```

## Prompt for AI assistants

Paste this into ChatGPT, Claude, Cursor, v0 or Lovable to adapt the block to your stack (Tailwind, Vue, Svelte…):

````text
I want to add the “Checkout stepper” block (Navigation) from AIMRAN Library to my project.

## What it is
Checkout steps with an elastic progress line, ticks that pop in and content that slides the right way.

## Chosen settings
- Color: #635bff
- Labels: yes

## What I need
1. Add it to my project with the same look and animation as the reference code: same sizes, colours, radii, shadows, timings and easing.
2. It is built with React + TypeScript, with framer-motion for the animation and lucide-react for the icons. If my project uses something else (Vue, Svelte, Astro, plain HTML…), translate it without losing any behaviour. If I use Tailwind, turn the styles into classes; if not, keep the CSS in its own file.
3. Expose the settings above as props (or CSS variables) with those values as defaults.
4. Make it work well on phones: nothing that depends only on hover, touch targets of at least 44 px and no overflow on 320 px screens.
5. Make it look right in light and dark mode.
6. Keep it accessible: semantic HTML, roles and aria-* where needed, visible focus and keyboard support.
7. Respect prefers-reduced-motion: with reduced motion, no big animations but the same end result.

## Reference code
```tsx
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Check } from "lucide-react";

const CHECKOUT_STEPS = [
  {
    label: "Cart",
    title: "Your cart",
    body: "Aura headphones + case",
    meta: "2 items",
  },
  { label: "Shipping", title: "Delivery", body: "Calle Mayor 14, Madrid", meta: "Thursday 12" },
  { label: "Payment", title: "Payment", body: "Visa ending in 4242", meta: "No fees" },
  {
    label: "Done",
    title: "Order confirmed",
    body: "Receipt sent by email",
    meta: "#A-20931",
  },
];

interface CheckoutStepperProps {
  color?: string;
  showLabels?: boolean;
}

export default function CheckoutStepper({
  color = "#635bff",
  showLabels = true,
}: CheckoutStepperProps) {
  const [step, setStep] = useState(0);
  const [dir, setDir] = useState(1);
  const reduce = useReducedMotion();
  const last = CHECKOUT_STEPS.length - 1;
  const current = CHECKOUT_STEPS[step]!;
  const done = step === last;

  const go = (i: number) => {
    setDir(i >= step ? 1 : -1);
    setStep(i);
  };

  const primary = step === 2 ? "Pay €48.90" : done ? "New order" : "Continue";
  const slide = reduce ? 0 : 28;

  return (
    <div className="w-[22rem] max-w-full rounded-[22px] bg-white p-5 text-[#1d1d1f] shadow-[0_1px_2px_rgba(0,0,0,0.04),0_12px_40px_rgba(0,0,0,0.08)] ring-1 ring-black/[0.05] dark:bg-[#1c1c1e] dark:text-[#f5f5f7] dark:shadow-[0_12px_40px_rgba(0,0,0,0.45)] dark:ring-white/10">
      <div className="relative">
        <div
          aria-hidden
          className="absolute left-3 right-3 top-3 h-[2px] -translate-y-1/2 rounded-full bg-black/[0.06] dark:bg-white/10"
        >
          <motion.div
            className="h-full rounded-full"
            style={{ backgroundColor: color }}
            initial={false}
            animate={{ width: (step / last) * 100 + "%" }}
            transition={{ type: "spring", stiffness: 140, damping: 22 }}
          />
        </div>
        <ol className="relative flex justify-between">
          {CHECKOUT_STEPS.map((s, i) => {
            const complete = i < step || done;
            const isCurrent = i === step && !done;
            return (
              <li key={s.label} className="relative flex w-6 flex-col items-center">
                <button
                  type="button"
                  disabled={i >= step || done}
                  onClick={() => go(i)}
                  aria-label={s.label + (complete ? " (hecho)" : "")}
                  aria-current={i === step ? "step" : undefined}
                  className="relative grid h-6 w-6 place-items-center rounded-full bg-white text-[11px] font-semibold tabular-nums outline-none transition-[box-shadow,background-color,color] duration-300 focus-visible:ring-2 focus-visible:ring-offset-2 enabled:hover:scale-110 disabled:cursor-default dark:bg-[#1c1c1e] dark:focus-visible:ring-offset-[#1c1c1e]"
                  style={{
                    backgroundColor: complete ? color : undefined,
                    color: complete ? "#fff" : isCurrent ? color : "#aeaeb2",
                    boxShadow: complete
                      ? "none"
                      : isCurrent
                        ? "inset 0 0 0 2px " + color + ", 0 0 0 5px " + color + "22"
                        : "inset 0 0 0 1.5px rgba(128,128,128,0.3)",
                    ["--tw-ring-color" as string]: color,
                  }}
                >
                  <AnimatePresence mode="popLayout" initial={false}>
                    {complete ? (
                      <motion.span
                        key="check"
                        initial={{ scale: 0, rotate: -45 }}
                        animate={{ scale: 1, rotate: 0 }}
                        transition={{ type: "spring", stiffness: 520, damping: 22 }}
                        className="grid place-items-center"
                      >
                        <Check className="h-3.5 w-3.5" strokeWidth={3} />
                      </motion.span>
                    ) : (
                      <motion.span
                        key="n"
                        initial={{ opacity: 0 }}
                        animate={{ opacity: 1 }}
                        exit={{ opacity: 0 }}
                      >
                        {i + 1}
                      </motion.span>
                    )}
                  </AnimatePresence>
                </button>
                {showLabels && (
                  <span
                    className="absolute top-8 whitespace-nowrap text-[11px] font-medium transition-colors duration-300"
                    style={{ color: i <= step ? undefined : "#aeaeb2" }}
                  >
                    {s.label}
                  </span>
                )}
              </li>
            );
          })}
        </ol>
      </div>

      <div className={"relative overflow-hidden " + (showLabels ? "mt-11" : "mt-6")}>
        <AnimatePresence mode="popLayout" custom={dir} initial={false}>
          <motion.div
            key={step}
            custom={dir}
            initial={{ x: dir * slide, opacity: 0, filter: "blur(4px)" }}
            animate={{ x: 0, opacity: 1, filter: "blur(0px)" }}
            exit={{ x: dir * -slide, opacity: 0, filter: "blur(4px)" }}
            transition={{ type: "spring", stiffness: 320, damping: 32 }}
          >
            <p className="text-[20px] font-semibold tracking-[-0.02em]">{current.title}</p>
            <div className="mt-3 flex items-center justify-between gap-3 border-t border-black/[0.06] pt-3 text-[13px] dark:border-white/10">
              <span className="truncate text-[#86868b]">{current.body}</span>
              <span className="shrink-0 font-medium tabular-nums">{current.meta}</span>
            </div>
          </motion.div>
        </AnimatePresence>
      </div>

      <div className="mt-5 flex items-center gap-2">
        <AnimatePresence initial={false}>
          {step > 0 && !done && (
            <motion.button
              type="button"
              onClick={() => go(step - 1)}
              initial={{ opacity: 0, x: -6 }}
              animate={{ opacity: 1, x: 0 }}
              exit={{ opacity: 0, x: -6 }}
              whileTap={{ scale: 0.96 }}
              className="h-10 rounded-full px-4 text-[13px] font-medium text-[#86868b] outline-none transition-colors hover:bg-black/[0.05] hover:text-[#1d1d1f] focus-visible:ring-2 focus-visible:ring-black/20 dark:hover:bg-white/10 dark:hover:text-[#f5f5f7] dark:focus-visible:ring-white/30"
            >
              Back
            </motion.button>
          )}
        </AnimatePresence>
        <motion.button
          type="button"
          layout
          whileHover={{ y: -1 }}
          whileTap={{ scale: 0.97, y: 0 }}
          transition={{ type: "spring", stiffness: 500, damping: 30 }}
          onClick={() => go(done ? 0 : step + 1)}
          className="ml-auto h-10 rounded-full px-5 text-[13px] font-semibold text-white outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-[#1c1c1e]"
          style={{
            backgroundColor: color,
            boxShadow:
              "inset 0 1px 0 rgba(255,255,255,0.2), 0 1px 2px rgba(0,0,0,0.12), 0 8px 20px " +
              color +
              "40",
            ["--tw-ring-color" as string]: color,
          }}
        >
          {primary}
        </motion.button>
      </div>
    </div>
  );
}
```

Give me the complete final code, ready to paste, and tell me in one line where to put it in my project.
````
