Send Plane Button
Clicking Send launches the paper plane off the button, the label slides over to Sent with a check, then everything resets.
Create a send button with a paper-plane launch in React and CSS. The button holds a plane icon and a label stack with "Send" and "Sent" in the same grid cell. On click, set data-state="sent" for 2.2s and disable the button. In that state, the plane plays a 600ms keyframe: a small pull back (translate(-4px, 4px)), then flies off to translate(60px, -40px) rotate(-12deg) scale(0.6) with opacity 0, using cubic-bezier(0.7, 0, 0.84, 0) so it accelerates away. The "Send" label slides up and out while "Sent" with a check slides in from below over 300ms, delayed 200ms. When the state resets, the plane fades back in at its start. Announce "Sent" with aria-live="polite". Clear the timeout on unmount. Under prefers-reduced-motion, swap labels and hide the plane without the flight.
Source
'use client'
import { useEffect, useRef, useState } from 'react'
import s from './demo.module.css'
export default function SendButton({ labels }: { labels: Record<string, string> }) {
const [sent, setSent] = useState(false)
const timer = useRef(0)
useEffect(() => () => clearTimeout(timer.current), [])
const send = () => {
setSent(true)
timer.current = window.setTimeout(() => setSent(false), 2200)
}
return (
<button type="button" className={s.button} data-state={sent ? 'sent' : 'idle'} disabled={sent} onClick={send}>
<svg className={s.plane} viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 3L3 10.5l7 2.5 2.5 7L21 3z" />
<path d="M10 13l4.5-4.5" />
</svg>
<span className={s.labels} aria-live="polite">
<span className={s.send}>{labels.send}</span>
<span className={s.sent}>✓ {labels.sent}</span>
</span>
</button>
)
}
.button {
display: inline-flex;
align-items: center;
gap: 10px;
height: 52px;
padding: 0 26px 0 22px;
overflow: hidden;
border: 0;
border-radius: var(--radius-sm);
background: var(--stage-accent);
color: var(--stage-accent-ink);
font: 600 var(--text-base) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.button:disabled { cursor: default; }
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }
.plane {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
transition: opacity 300ms;
}
.labels {
display: grid;
overflow: hidden;
}
.send, .sent {
grid-area: 1 / 1;
transition: transform 300ms var(--ease-out), opacity 300ms;
}
.sent { transform: translateY(110%); opacity: 0; }
.button[data-state='sent'] .plane { animation: fly 600ms var(--ease-in) forwards; }
.button[data-state='sent'] .send { transform: translateY(-110%); opacity: 0; transition-delay: 200ms; }
.button[data-state='sent'] .sent { transform: none; opacity: 1; transition-delay: 200ms; }
@keyframes fly {
20% { transform: translate(-4px, 4px); }
to { transform: translate(60px, -40px) rotate(-12deg) scale(0.6); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.send, .sent { transition: none; }
.button[data-state='sent'] .plane { animation: none; opacity: 0; }
}