- Carrito
- Envío
- Pago
- Listo
Tu carrito
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.