1. Carrito
  2. Envío
  3. Pago
  4. Listo

Tu carrito

Auriculares Aura + funda2 artículos
React + MotionNavegación

Stepper de checkout

Pasos de compra con una línea de progreso elástica, checks que saltan y contenido que se desliza según la dirección.

Ajustes

Código

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

const CHECKOUT_STEPS = [
  {
    label: "Carrito",
    title: "Tu carrito",
    body: "Auriculares Aura + funda",
    meta: "2 artículos",
  },
  { label: "Envío", title: "Entrega", body: "Calle Mayor 14, Madrid", meta: "Jueves 12" },
  { label: "Pago", title: "Pago", body: "Visa terminada en 4242", meta: "Sin comisiones" },
  {
    label: "Listo",
    title: "Pedido confirmado",
    body: "Recibo enviado por correo",
    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 ? "Pagar 48,90 €" : done ? "Nuevo pedido" : "Continuar";
  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"
            >
              Atrás
            </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 para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Más en Navegación

Dónde usarlo

El pago es donde una tienda online pierde a la gente, y saber cuánto falta ayuda mucho. Este stepper de checkout lleva del carrito al envío, el pago y la confirmación, y el botón principal pasa a «Pagar 48,90 €» en el paso que toca. También sirve para cualquier formulario por pasos, como una reserva o un registro.

No pases de tres a cinco pasos, que los puntos se aprietan. Los pasos hechos se pueden pulsar para volver y los siguientes no, así evitas pedidos a medias. Si la tarjeta queda estrecha, quita las etiquetas y deja que hablen los números.