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

Tu carrito

Auriculares Aura + funda2 artículos
React + MotionNavigation

É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: "Expédition", 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

Où 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.