React + MotionMicro-interactions

Envoi en avion en papier

L’avion prend son élan, décolle du bouton qui rétrécit, revient sous forme de coche dessinée puis regagne sa place en planant.

Réglages

Code

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Send } from "lucide-react";

type SendState = "idle" | "flying" | "sent";

interface PaperPlaneSendProps {
  label?: string;
  sentLabel?: string;
  color?: string;
}

const sendSpring = { type: "spring" as const, stiffness: 520, damping: 34 };
const sendText = {
  initial: { opacity: 0, y: 10, filter: "blur(4px)" },
  animate: { opacity: 1, y: 0, filter: "blur(0px)" },
  exit: { opacity: 0, y: -10, filter: "blur(4px)" },
};

export default function PaperPlaneSend({
  label = "Envoyer",
  sentLabel = "Envoyé",
  color = "#0a84ff",
}: PaperPlaneSendProps) {
  const [state, setState] = useState<SendState>("idle");
  const [trip, setTrip] = useState(0);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  const reduce = useReducedMotion();

  useEffect(() => () => timers.current.forEach(clearTimeout), []);

  const send = () => {
    if (state !== "idle") return;
    setState("flying");
    timers.current = [
      setTimeout(() => setState("sent"), reduce ? 0 : 560),
      setTimeout(() => {
        setState("idle");
        setTrip((t) => t + 1);
      }, 2400),
    ];
  };

  return (
    <motion.button
      type="button"
      layout
      onClick={send}
      aria-disabled={state !== "idle"}
      whileTap={{ scale: state === "idle" ? 0.95 : 1 }}
      transition={sendSpring}
      className="relative flex h-12 items-center justify-center gap-2 px-6 text-[15px] font-medium text-white outline-none focus-visible:ring-2 focus-visible:ring-black/25 focus-visible:ring-offset-2 dark:focus-visible:ring-white/40 dark:focus-visible:ring-offset-black"
      style={{
        backgroundColor: color,
        borderRadius: 999,
        boxShadow: "inset 0 1px 0 rgba(255,255,255,0.28), inset 0 -1px 0 rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.14), 0 10px 24px -10px " + color,
      }}
    >
      <motion.span layout="position" className="relative grid h-[18px] w-[18px] place-items-center">
        <AnimatePresence initial={false}>
          {state === "sent" ? (
            <motion.svg
              key="check"
              viewBox="0 0 24 24"
              className="absolute h-[18px] w-[18px]"
              fill="none"
              stroke="currentColor"
              strokeWidth={2.5}
              strokeLinecap="round"
              strokeLinejoin="round"
              exit={{ opacity: 0, scale: 0.6 }}
              transition={{ duration: 0.15 }}
            >
              <motion.path
                d="M5 12.5l4.5 4.5L19 7.5"
                initial={{ pathLength: 0 }}
                animate={{ pathLength: 1 }}
                transition={{ duration: 0.32, ease: [0.65, 0, 0.35, 1] }}
              />
            </motion.svg>
          ) : (
            <motion.span
              key={"plane-" + trip}
              className="absolute grid place-items-center"
              initial={trip === 0 ? false : { x: -26, y: 12, rotate: 18, opacity: 0 }}
              animate={
                state === "flying" && !reduce
                  ? {
                      x: [0, -4, 110],
                      y: [0, 3, -80],
                      rotate: [0, 14, -10],
                      scale: [1, 0.92, 0.7],
                      opacity: [1, 1, 0],
                      filter: ["blur(0px)", "blur(0px)", "blur(1.5px)"],
                    }
                  : { x: 0, y: 0, rotate: 0, scale: 1, opacity: 1 }
              }
              exit={{ opacity: 0 }}
              transition={
                state === "flying"
                  ? { duration: 0.56, times: [0, 0.28, 1], ease: [0.55, 0, 0.8, 0.2] }
                  : { type: "spring", stiffness: 260, damping: 22 }
              }
            >
              <Send className="h-[18px] w-[18px]" strokeWidth={1.75} />
            </motion.span>
          )}
        </AnimatePresence>
      </motion.span>
      <AnimatePresence mode="popLayout" initial={false}>
        {state !== "flying" && (
          <motion.span
            key={state}
            layout="position"
            className="whitespace-nowrap leading-5"
            initial={sendText.initial}
            animate={sendText.animate}
            exit={sendText.exit}
            transition={sendSpring}
          >
            {state === "sent" ? sentLabel : label}
          </motion.span>
        )}
      </AnimatePresence>
      <span className="sr-only" aria-live="polite">
        {state === "sent" ? sentLabel : ""}
      </span>
    </motion.button>
  );
}

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 Micro-interactions

Où l’utiliser

Une animation sur le bouton d’envoi confirme que le message est bien parti, ce qui compte plus qu’on ne le croit sur un formulaire de contact, une inscription à la newsletter ou une messagerie. Ici l’avion en papier prend son élan, s’envole, et une coche se dessine à côté de « Envoyé ». Puis il revient en planant.

Déclenchez le vol une fois le formulaire vraiment envoyé, pas au clic, sinon vous promettez quelque chose qui peut échouer. L’aller-retour dure un peu plus de deux secondes. Gardez deux libellés courts pour que le bouton ne change pas trop de largeur.