Dispatch

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/button-dispatch
Source on GitHub

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.

More buttons

See all
Dissolve
Foil
Marquee
Rewind