Dispatch
import { useEffect, useRef, useState } from 'react';
const css = `
@keyframes btn-dispatch-fold { 0% { transform: scale(0.4) rotate(0deg); opacity: 0; } 30% { transform: scale(1) rotate(-8deg); opacity: 1; } 100% { transform: scale(1) rotate(-8deg); opacity: 1; } }
/* The glide: out to the right and up in an arc, banking as it goes. */
@keyframes btn-dispatch-fly {
0% { transform: translate(0, 0) rotate(-8deg); opacity: 1; }
40% { transform: translate(46px, -10px) rotate(-18deg); }
100% { transform: translate(140px, -70px) rotate(-30deg) scale(0.6); opacity: 0; }
}
@keyframes btn-dispatch-trail { from { stroke-dashoffset: 100; opacity: 0.9; } to { stroke-dashoffset: 0; opacity: 0; } }
.btn-dispatch {
position: relative;
display: inline-grid;
place-items: center;
width: 150px;
height: 48px;
border: none;
border-radius: 12px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 10px 22px -12px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
transition: background-color 0.3s, color 0.3s;
}
.btn-dispatch:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.btn-dispatch > span { grid-area: 1 / 1; display: inline-flex; align-items: center; gap: 8px; transition: opacity 0.25s, transform 0.35s cubic-bezier(0.3, 1.3, 0.5, 1); }
.btn-dispatch svg { width: 16px; height: 16px; }
.btn-dispatch__sent { opacity: 0; transform: translateY(8px); }
.btn-dispatch[data-state='fly'] .btn-dispatch__send, .btn-dispatch[data-state='sent'] .btn-dispatch__send { opacity: 0; transform: translateY(-8px); }
.btn-dispatch[data-state='sent'] { background: light-dark(#ebedf0, #232529); color: light-dark(#373a40, #f1f2f3); box-shadow: inset 0 0 0 1px light-dark(#cbced3, #393c41); }
.btn-dispatch[data-state='sent'] .btn-dispatch__sent { opacity: 1; transform: none; }
.btn-dispatch__plane { position: absolute; left: 60px; top: 10px; width: 30px; height: 28px; overflow: visible; opacity: 0; pointer-events: none; }
.btn-dispatch__plane path { fill: light-dark(#fbfcfd, #121417); stroke: light-dark(#6d7179, #7d8087); stroke-width: 0.8; stroke-linejoin: round; }
.btn-dispatch__plane .btn-dispatch__crease { fill: none; stroke: light-dark(#6d7179, #7d8087); }
.btn-dispatch[data-state='fly'] .btn-dispatch__plane { animation: btn-dispatch-fold 0.3s ease-out both, btn-dispatch-fly 0.9s cubic-bezier(0.45, 0, 0.55, 1) 0.32s both; }
.btn-dispatch__trail { position: absolute; left: 70px; top: -48px; width: 150px; height: 80px; overflow: visible; pointer-events: none; }
.btn-dispatch__trail path { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1; stroke-dasharray: 3 4; opacity: 0; }
.btn-dispatch[data-state='fly'] .btn-dispatch__trail path { animation: btn-dispatch-trail 1s ease-out 0.4s both; stroke-dasharray: 100; }
@media (prefers-reduced-motion: reduce) {
.btn-dispatch__plane, .btn-dispatch__trail path { animation: none !important; opacity: 0 !important; }
}
`;
export function DispatchButton() {
const [state, setState] = useState<'idle' | 'fly' | 'sent'>('idle');
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const send = () => {
if (state !== 'idle') return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
setState(reduce ? 'sent' : 'fly');
timers.current = [
setTimeout(() => setState('sent'), reduce ? 0 : 1150),
setTimeout(() => setState('idle'), 3200),
];
};
return (
<>
<style>
{css}
</style>
<button type="button" className="btn-dispatch" data-state={state} onClick={send} aria-live="polite">
<svg className="btn-dispatch__trail" viewBox="0 0 150 80" aria-hidden="true">
<path d="M0 64C40 62 80 40 140 6" pathLength={100} />
</svg>
<span className="btn-dispatch__send" aria-hidden={state !== 'idle'}>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 12 20 4l-6 16-3-7Z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
</svg>
Send
</span>
<span className="btn-dispatch__sent" aria-hidden={state !== 'sent'}>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m5 12.5 4.5 4.5L19 7.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Sent
</span>
<svg className="btn-dispatch__plane" viewBox="0 0 30 28" aria-hidden="true">
<path d="M1 13 29 1 19 27 14 17Z" />
<path className="btn-dispatch__crease" d="M29 1 14 17" />
</svg>
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnDispatch(el) on the mounted element instead.
<button type="button" class="btn-dispatch" data-state="idle" aria-live="polite">
<svg class="btn-dispatch__trail" viewBox="0 0 150 80" aria-hidden="true">
<path d="M0 64C40 62 80 40 140 6" pathLength="100"></path>
</svg>
<span class="btn-dispatch__send" aria-hidden="false"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 12 20 4l-6 16-3-7Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"></path></svg>Send</span>
<span class="btn-dispatch__sent" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></svg>Sent</span>
<svg class="btn-dispatch__plane" viewBox="0 0 30 28" aria-hidden="true">
<path d="M1 13 29 1 19 27 14 17Z"></path>
<path class="btn-dispatch__crease" d="M29 1 14 17"></path>
</svg>
</button>@keyframes btn-dispatch-fold { 0% { transform: scale(0.4) rotate(0deg); opacity: 0; } 30% { transform: scale(1) rotate(-8deg); opacity: 1; } 100% { transform: scale(1) rotate(-8deg); opacity: 1; } }
/* The glide: out to the right and up in an arc, banking as it goes. */
@keyframes btn-dispatch-fly {
0% { transform: translate(0, 0) rotate(-8deg); opacity: 1; }
40% { transform: translate(46px, -10px) rotate(-18deg); }
100% { transform: translate(140px, -70px) rotate(-30deg) scale(0.6); opacity: 0; }
}
@keyframes btn-dispatch-trail { from { stroke-dashoffset: 100; opacity: 0.9; } to { stroke-dashoffset: 0; opacity: 0; } }
.btn-dispatch {
position: relative;
display: inline-grid;
place-items: center;
width: 150px;
height: 48px;
border: none;
border-radius: 12px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 10px 22px -12px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
transition: background-color 0.3s, color 0.3s;
}
.btn-dispatch:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.btn-dispatch > span { grid-area: 1 / 1; display: inline-flex; align-items: center; gap: 8px; transition: opacity 0.25s, transform 0.35s cubic-bezier(0.3, 1.3, 0.5, 1); }
.btn-dispatch svg { width: 16px; height: 16px; }
.btn-dispatch__sent { opacity: 0; transform: translateY(8px); }
.btn-dispatch[data-state='fly'] .btn-dispatch__send, .btn-dispatch[data-state='sent'] .btn-dispatch__send { opacity: 0; transform: translateY(-8px); }
.btn-dispatch[data-state='sent'] { background: light-dark(#ebedf0, #232529); color: light-dark(#373a40, #f1f2f3); box-shadow: inset 0 0 0 1px light-dark(#cbced3, #393c41); }
.btn-dispatch[data-state='sent'] .btn-dispatch__sent { opacity: 1; transform: none; }
.btn-dispatch__plane { position: absolute; left: 60px; top: 10px; width: 30px; height: 28px; overflow: visible; opacity: 0; pointer-events: none; }
.btn-dispatch__plane path { fill: light-dark(#fbfcfd, #121417); stroke: light-dark(#6d7179, #7d8087); stroke-width: 0.8; stroke-linejoin: round; }
.btn-dispatch__plane .btn-dispatch__crease { fill: none; stroke: light-dark(#6d7179, #7d8087); }
.btn-dispatch[data-state='fly'] .btn-dispatch__plane { animation: btn-dispatch-fold 0.3s ease-out both, btn-dispatch-fly 0.9s cubic-bezier(0.45, 0, 0.55, 1) 0.32s both; }
.btn-dispatch__trail { position: absolute; left: 70px; top: -48px; width: 150px; height: 80px; overflow: visible; pointer-events: none; }
.btn-dispatch__trail path { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1; stroke-dasharray: 3 4; opacity: 0; }
.btn-dispatch[data-state='fly'] .btn-dispatch__trail path { animation: btn-dispatch-trail 1s ease-out 0.4s both; stroke-dasharray: 100; }
@media (prefers-reduced-motion: reduce) {
.btn-dispatch__plane, .btn-dispatch__trail path { animation: none !important; opacity: 0 !important; }
}function initBtnDispatch(root) {
var send = root.querySelector('.btn-dispatch__send');
var sent = root.querySelector('.btn-dispatch__sent');
var state = 'idle';
var timers = [];
function set(s) {
state = s;
root.setAttribute('data-state', s);
send.setAttribute('aria-hidden', String(s !== 'idle'));
sent.setAttribute('aria-hidden', String(s !== 'sent'));
}
function onClick() {
if (state !== 'idle') return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// The plane flies off, then the button confirms, then it resets for the next message.
set(reduce ? 'sent' : 'fly');
timers = [
setTimeout(function () { set('sent'); }, reduce ? 0 : 1150),
setTimeout(function () { set('idle'); }, 3200),
];
}
root.addEventListener('click', onClick);
return function () {
timers.forEach(clearTimeout);
root.removeEventListener('click', onClick);
};
}
document.querySelectorAll('.btn-dispatch').forEach(initBtnDispatch);Goal: Create a "Dispatch" button.
Visual behavior: A send button that folds into a paper plane: on click the plane glides off in an arc, banking as it goes and leaving a fading trail, and the button settles on Sent.
Technique:
- A small state machine on data-state: idle, fly and sent; a timer moves from fly to sent once the plane has gone, and back to idle a couple of seconds later.
- The plane folds in with a scale keyframe, then flies with a second keyframe of translate and rotate stops along an arc; a dashed SVG trail draws behind it with a dash-offset animation.
- Send slides up and away and Sent slides in, with only the showing label exposed to screen readers.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. The button is aria-live="polite", so the change to Sent is announced. Under prefers-reduced-motion it goes straight to Sent without the flight.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Dispatch" button.
Visual behavior: A send button that folds into a paper plane: on click the plane glides off in an arc, banking as it goes and leaving a fading trail, and the button settles on Sent.
Technique:
- A small state machine on data-state: idle, fly and sent; a timer moves from fly to sent once the plane has gone, and back to idle a couple of seconds later.
- The plane folds in with a scale keyframe, then flies with a second keyframe of translate and rotate stops along an arc; a dashed SVG trail draws behind it with a dash-offset animation.
- Send slides up and away and Sent slides in, with only the showing label exposed to screen readers.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. The button is aria-live="polite", so the change to Sent is announced. Under prefers-reduced-motion it goes straight to Sent without the flight.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A send button that folds into a paper plane: on click the plane glides off in an arc, banking as it goes and leaving a fading trail, and the button settles on Sent.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, draw
- Techniques
- keyframes, svg, stroke dashoffset, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> with a visible label; decoration is aria-hidden. The button is aria-live="polite", so the change to Sent is announced. Under prefers-reduced-motion it goes straight to Sent without the flight.