Paper plane send button
The plane pulls back, takes off from the shrinking button and comes back as a drawn check before gliding home.
Settings
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 = "Send",
sentLabel = "Sent",
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 for AI
Paste it into ChatGPT, Claude or Cursor and it will adapt the block to your project with these settings, even if you don’t use React.
More in Microinteractions
Where to use it
A send button animation tells people their message left, which matters more than it sounds on a contact form, a newsletter signup or the send button of a chat. Here the paper plane winds up, flies off the button, and a check draws itself next to “Sent”. Then the plane glides back so the button is ready again.
Trigger the flight once your form has actually been sent, not on the click, or you promise something that may fail. The whole trip takes a little over two seconds. Keep both labels short so the button does not jump in width.