React + MotionMicrointeracciones

Enviar con avión de papel

El avión coge impulso, despega del botón, que se encoge, y vuelve como un check dibujado antes de regresar planeando.

Ajustes

Código

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 = "Enviar",
  sentLabel = "Enviado",
  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 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 Microinteracciones

Dónde usarlo

Animar el botón de enviar confirma que el mensaje ha salido, y eso importa más de lo que parece en un formulario de contacto, el alta de una newsletter o un chat. Aquí el avión de papel coge impulso, sale volando y aparece un check junto a «Enviado». Luego vuelve planeando.

Lanza el vuelo cuando el formulario se haya enviado de verdad, no con el clic, o prometerás algo que puede fallar. El recorrido completo dura algo más de dos segundos. Deja los dos textos cortos para que el botón no cambie mucho de ancho.