- Carrito
- Envío
- Pago
- Listo
Tu carrito
Étapes de paiement
Un tunnel de commande avec une ligne de progression élastique, des coches qui surgissent et un contenu qui glisse.
Réglages
Code
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Check } from "lucide-react";
const CHECKOUT_STEPS = [
{
label: "Panier",
title: "Votre panier",
body: "Écouteurs Aura + étui",
meta: "2 articles",
},
{ label: "Livraison", title: "Livraison", body: "Calle Mayor 14, Madrid", meta: "Jeudi 12" },
{ label: "Paiement", title: "Paiement", body: "Visa se terminant par 4242", meta: "Sans frais" },
{
label: "Terminé",
title: "Commande confirmée",
body: "Reçu envoyé par e-mail",
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 ? "Payer 48,90 €" : done ? "Nouvelle commande" : "Continuer";
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"
>
Retour
</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 pour l’IA
Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.
Plus dans Navigation
Voir tout : NavigationOù l’utiliser
C’est au paiement qu’une boutique en ligne perd le plus de monde, et savoir combien d’étapes restent rassure. Ce stepper de checkout mène du panier à la livraison, au paiement et à la confirmation, et le bouton principal devient « Payer 48,90 € » au bon moment. Il convient aussi à tout formulaire en plusieurs étapes.
Restez entre trois et cinq étapes, au-delà les points se serrent. On peut cliquer sur une étape terminée pour revenir, pas sur les suivantes, ce qui évite les commandes à moitié remplies. Si la carte est étroite, masquez les libellés et laissez parler les numéros.