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.