GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Send Button Animation CSS: 9 Fold-and-Fly Buttons

A send button should show that something left — 01 folds into a paper plane that banks along a curve, 02 springs back as its shadow tracks the height, and 08

Every one of the nine boards holds a single real <button> element. What changes from board to board is how far the motion travels once that button is pressed, from a rebound inside its own outline to a chat bubble that lands three rows up.

Press Nine Send Buttons and Make Them Fail on Purpose

Auto-plays · click a tile to jump to its section · all nine in one zip

Until you touch a board, it presses its own button on a timer and then resets (02 every 1.3 seconds, 01, 05 and 06 every 3 seconds). One tap or one key press inside a board ends that loop (on 06, just moving the pointer over the board is enough), puts the button back in its starting state and leaves every following press to you. Item 08 is rigged so its first attempt fails: press it once to watch the shake, then press again to follow the retry through to its mint success state.

Send Button Animation CSS in 9 Buttons: Fold, Spring, Progress Ring, Trail, Envelope, Gloss, Letters, Shake and Chat Bubble

The order follows how far the motion spreads from the button. Items 01 to 03 change the button body itself by folding it, pressing it and shrinking it into a circle; 04 and 05 send something away from it, a plane or an envelope; 06 and 07 touch only the light on its surface and the letters of its label; and 08 and 09 move the input row and the chat thread along with it.

Fixed 16ms steps in place of keyframes

The nine stylesheets contain no @keyframes rule, and the only transition in them is transition: none !important inside the reduced-motion block. Script does all of the moving. Timestamps from requestAnimationFrame are added up (capped at 100ms per frame), a fixed 16ms step named FIXED_DT runs up to six times per frame, and the board is drawn once after those steps. Springs, drag and inertia land on different numbers when the step size changes, so the step is fixed to give the same overshoot and the same settle time on screens with different refresh rates. The shared header of all nine files defines a seeded makeRng, yet only 03, 04 and 07 call it, 03 for its lumpy progress, 04 for particle spread and 07 through its noise.

A 480 by 300 scene shrunk as one piece

Each button and its surroundings are laid out on a 480 by 300 px scene, and fitScene() takes the box width divided by 480 as k and applies scale(k) to the whole scene. In a 480 by 300 window, k measured 0.8729. Pointer positions and element measurements go the other way, divided by k to return to scene coordinates, and the four canvas items (01, 02, 04 and 05) cap the device pixel ratio at 2.

What a two-second capture recorded for each button

The renderer filmed each board for two seconds at 480 by 300, took 24 stills, and compared every still with the one before it. That makes 23 comparisons per board, so the moving-frame column counts out of 23.

Item Accumulated area Moving frames Mean intensity
01 Paper plane fold and flight 12.5% 21/23 38.3
02 Spring press and shadow 9.2% 13/23 50.7
03 Circle, progress ring and check 10.9% 20/23 50.4
04 Drag and noise trail 14.1% 22/23 68.3
05 Envelope flap and seal 24.4% 18/23 43.6
06 Gloss band and tilt highlight 17.1% 23/23 43.7
07 Letter scatter and regather 4.2% 13/23 87.7
08 Failure shake 6.1% 15/23 55.6
09 Chat bubble arc 22.2% 18/23 72.2

Three items stay under 10% area: 02, 07 and 08. All three move inside the footprint of the button itself, so a small area is the expected result rather than a weak one. The site's reference piece, the canvas water drop and black hole scenes, fills the whole frame with computation; a button is judged instead by refinement inside a small face, meaning the spring overshoot at release, a shadow that drifts and blurs with height, a tilt that trails the pointer by a beat, a gloss band crossing the face, and a noise direction read separately for each letter.

Why Should a Send Button Keep Moving After It Is Pressed?

A button that sits still after a press makes people doubt the press, and many of them press again. A contact form, a sign-up or checkout confirmation, a comment box and a chat input all leave a short gap between the press and the result, and that gap can show only three states: sending, sent, or not sent.

The nine items split those states between them. The ring in 03 and the gloss band in 06 cover sending; the flying paper of 01 and 04, the envelope of 05, the swapped label of 07 and the bubble that lands in 09 cover sent; 08 reports not sent with a shake and then turns into a retry. A chat box is where a send message button animation earns its keep, because the bubble in 09 shows the message arriving in the thread, which a label change cannot do. Item 02 handles the moment before any result, the feel of the press itself.

Choose by how heavy the screen is. On a checkout, where a second press can mean a second charge, the right kind of submit button animation css stays inside the button's footprint, like the short rebound of 02 or the ring of 03. On an inquiry form or an invite, where the finished send is the reward, the larger scenes of 01, 04 and 05 fit better.

01A Send Button That Folds Into a Paper Plane and Banks Along a Curve

Pressing splits the 168 by 52 button face into four facets that fold in three stages of 240, 240 and 300ms: two corners flip 180° around their fold lines, then the upper and lower halves rise ±64° around the center line, narrowing the 52px face into a 22.8px V-shaped plane. For the next 1,100ms the plane flies a cubic Bezier curve out to depth 320, turning its nose to the angle of the curve's derivative, while its floor shadow slides from 10px to 35.6px down and to the right and fades from 0.16 to 0.064 opacity. It suits a contact form where the send should read as one scene, and a half-transparent mint placeholder covers the button's spot during the flight so no empty hole is left behind.

A Send Button That Folds Into a Paper Plane and Banks Along a Curve
Open in a new tab

01 · A Send Button That Folds Into a Paper Plane and Banks Along a Curve — Measured area 12.511% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

FOLD_STEPSFLIGHT_CTRLBANK_GAIN
    function foldedFacets(t) {
        const out = FACETS.map(function (f) {
            return f.map(function (q) { return [q[0], q[1], 0]; });
        });
        let start = 0;
        for (const st of FOLD_STEPS) {
            const p = easeInOut((t - start) / st.ms);
            start += st.ms;
            if (p <= 0) {
                continue;
            }
            for (const hg of st.hinges) {
                const a = [hg.a[0], hg.a[1], 0];
                const len = Math.hypot(hg.b[0] - hg.a[0], hg.b[1] - hg.a[1]);
                const d = [(hg.b[0] - hg.a[0]) / len, (hg.b[1] - hg.a[1]) / len, 0];
                const ang = hg.deg * p * Math.PI / 180;
                for (const fi of hg.facets) {
                    out[fi] = out[fi].map(function (q) { return rotateAround(q, a, d, ang); });
                }
            }
        }
        return out;
    }
    // Floor shadow — shift the same facets down and right in proportion to depth z (10 + 0.08z) and paint them lighter. The farther away, the more the shadow drops and fades
    function drawShadow(facets, pos, heading, roll, scale) {
        const off = 10 + pos[2] * 0.08;
        ctx.fillStyle = rgba(INK, clamp(0.16 - pos[2] * 0.0003, 0.05, 0.16));
        for (const f of facets) {
            ctx.beginPath();
            f.forEach(function (q, j) {
                const s = project(place(q, pos, heading, roll, scale));
                if (j === 0) {
                    ctx.moveTo(s[0] + off, s[1] + off * 1.4);
                }
                else {
                    ctx.lineTo(s[0] + off, s[1] + off * 1.4);
                }
            });
            ctx.closePath();
            ctx.fill();
        }
    }

02A Submit Button That Springs Back While Its Shadow Tracks the Height

While it is held, the target depth moves to 12px and a spring with stiffness 520 and damping ratio 0.45 is integrated every 16ms, so on release the face rises 2.04px (17.0%) past its rest height and settles inside ±0.5px after 208ms. The shadow recomputes its offset as 2 + 0.85h, its blur as 4 + 1.6h and its opacity from the face height h alone, tightening from 15.6px and 29.6px at rest to 5.4px and 10.4px at full press, and the button widens by as much as it squashes vertically, in proportion to velocity, so its area holds. It fits sign-up and payment confirm buttons where the press should feel physical, and after the click only the label changes, to Signed up, while the color stays as it was.

A Submit Button That Springs Back While Its Shadow Tracks the Height
Open in a new tab

02 · A Submit Button That Springs Back While Its Shadow Tracks the Height — Measured area 9.168% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPRING_KSPRING_DAMPSHADOW_LIFT_PX
    // One damped-spring step — mass 1, stiffness stiff, damping ratio zeta. Velocity changes first, then position moves by that velocity (semi-implicit Euler)
    function springStep(s, stiff, zeta, dtMs) {
        const dt = dtMs / 1000;
        const c = 2 * zeta * Math.sqrt(stiff);
        const a = -stiff * (s.x - s.target) - c * s.v;
        s.v += a * dt;
        s.x += s.v * dt;
    }
    // Three shadow values — proportional to the face height h (px). Higher means farther, blurrier and lighter
    function shadowOffset(h) { return 2 + h * 0.85; }
    function shadowBlur(h) { return 4 + h * 1.6; }
    function shadowAlpha(h) { return clamp(0.42 - h * 0.012, 0.12, 0.42); }

03A Button That Shrinks to a Circle, Runs a Progress Ring and Ends in a Check

Pressing shrinks the 192 by 56 pill into a 56 by 56 circle over 360ms while holding its center, and a ring of radius 36 and stroke width 4 around it reveals its line by setting stroke-dashoffset to the circumference 226.19 × (1 − progress). Progress fills in lumps driven by a seed-466 random source and the ring chases it, closing 30% of the remaining gap each step; once it is full, the face fills with color for 160ms and a check 42.46 units long is drawn at constant speed over 300ms, reaching its bend at 99.9ms. It fits save and form-submit waits, and the first press reaches the Submitted state at 1,952ms, of which 1,120ms is the ring filling up.

A Button That Shrinks to a Circle, Runs a Progress Ring and Ends in a Check
Open in a new tab

03 · A Button That Shrinks to a Circle, Runs a Progress Ring and Ends in a Check — Measured area 10.944% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

SHRINK_MSRING_STROKECHECK_DRAW_MS
    // Redraw the body at pill width w — move x along with it to keep the center in place
    function setPill(w) {
        setNum(body, 'x', SVG_CX - w / 2);
        setNum(body, 'width', w);
    }
    function setRing(p) { setNum(ring, 'stroke-dashoffset', RING_C * (1 - clamp(p, 0, 1))); }
    function setCheck(p) {
        setNum(check, 'stroke-dashoffset', checkLen * (1 - clamp(p, 0, 1)));
        // A zero-length dash still prints its round cap as a dot — hide the check entirely until drawing starts
        check.style.opacity = p <= 0 ? '0' : '';
    }
    function setFill(t) { body.setAttribute('fill', rgba(mix(LILAC, MINT, clamp(t, 0, 1)), 1)); }
    function setRingAlpha(a) {
        ring.style.opacity = a.toFixed(3);
        track.style.opacity = a.toFixed(3);
    }
    // Halo — the progress the ring shows sets both radius (RING_R + 4 → + 38) and opacity. Progress spreads to the wide area outside the circle
    function setHalo(p, a) {
        setNum(halo, 'r', RING_R + 4 + 34 * clamp(p, 0, 1));
        halo.style.opacity = clamp(a, 0, 1).toFixed(3);
    }

04Trail Particles That Scatter With Air Drag and Noise Behind the Flight

The plane leaving the button travels a cubic Bezier path for 1,150ms and drops three particles per step against its direction of travel, and each particle's speed is multiplied by 0.955 every step, so it halves in 241ms. A 2D value noise read at the particle's position picks the push direction while the strength stays fixed at 0.06, which lets 100 particles released from one point drift an average of 62.6px over their 69-step life and spread out like smoke. It suits newsletter sign-ups and invite sends; the flight spawns 207 particles into a pool of 150 slots, so the pool is already full mid-flight and the oldest slots get overwritten first.

Trail Particles That Scatter With Air Drag and Noise Behind the Flight
Open in a new tab

04 · Trail Particles That Scatter With Air Drag and Noise Behind the Flight — Measured area 14.133% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

TRAIL_COUNTAIR_DRAGTRAIL_NOISE
    // One step — shrink the velocity by drag, push toward the direction the local noise value points, then move
    function advance(i) {
        vx[i] *= AIR_DRAG;
        vy[i] *= AIR_DRAG;
        const ang = noise(px[i] * NOISE_SCALE, py[i] * NOISE_SCALE + fieldT * NOISE_DRIFT) * Math.PI * 2;
        vx[i] += Math.cos(ang) * TRAIL_NOISE;
        vy[i] += Math.sin(ang) * TRAIL_NOISE - 0.004;
        px[i] += vx[i];
        py[i] += vy[i];
        age[i] += FIXED_DT;
        if (age[i] >= LIFE_MS) {
            alive[i] = 0;
        }
    }

05A Mail Send Button That Becomes an Envelope, Folds Its Flap and Seals

The button stretches into a 180 by 112 envelope over 160ms to take in the letter, then the triangular flap closes from 180° to 0° around its top edge in 480ms, its tip projected with a focal length of 500 so that at 90° it bulges 66 toward the viewer. At 240ms, the moment the flap passes 90°, drawing switches from the inside color behind the body to the outside color on top, and the seal sticker, driven by a spring that starts with a velocity of 12, swells to 1.41 times its size before settling at 1. It fits email sends and inquiry-received screens, and one complete send takes 2,060ms.

A Mail Send Button That Becomes an Envelope, Folds Its Flap and Seals
Open in a new tab

05 · A Mail Send Button That Becomes an Envelope, Folds Its Flap and Seals — Measured area 24.408% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

FLAP_DEGFLAP_MSSEAL_POP
    // Flap tip — raise it by deg around the top edge (y = EY). At 90° it sticks out furthest toward the screen (negative z)
    function flapTip(deg) {
        const a = deg * Math.PI / 180;
        return [ECX, EY + FLAP_H * Math.cos(a), -FLAP_H * Math.sin(a)];
    }
    // Past 90°? Then the inside of the flap shows and it is drawn behind the body
    function flapInsideVisible(deg) { return deg > 90; }
    function flapDegAt(t) { return FLAP_DEG * (1 - easeInOut(t / FLAP_MS)); }

06A Button With a Gloss Band While Sending and a Highlight That Follows the Pointer Tilt

During the 1,700ms of sending, a 46-wide gloss band slanted by skewX(-22deg) crosses the button from −92px to 278px every 820ms, and the button tilts up to 14° toward the pointer by closing 12% of the remaining gap each step, with the target measured from the button center and normalized by half the width plus 40 and half the height plus 60. The highlight center is recomputed from that lagging tilt rather than from the pointer, so when the tilt trails the demo pointer by up to 2.8° the light trails by the same amount and the two stay lined up. It suits checkout and premium upgrade buttons that need to hold the eye; reaching 90% of the target tilt takes 304ms, and the target drops back to 0 once the pointer leaves the board.

A Button With a Gloss Band While Sending and a Highlight That Follows the Pointer Tilt
Open in a new tab

06 · A Button With a Gloss Band While Sending and a Highlight That Follows the Pointer Tilt — Measured area 17.132% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SWEEP_MSGLOSS_WIDTHTILT_LERP
    // One inertial lerp step — go only TILT_LERP of the remaining distance instead of jumping to the goal
    function follow(cur, goal) { return cur + (goal - cur) * TILT_LERP; }
    // Gloss band x — in one pass, from outside on the left (−2 × width) to outside on the right (button width + width)
    function glossX(t) {
        const p = (t % SWEEP_MS) / SWEEP_MS;
        return lerp(-GLOSS_WIDTH * 2, BTN_W + GLOSS_WIDTH, easeInOut(p));
    }
    // Highlight center — computed from the current tilt (tx, ty), not from the pointer. That is why the reflection lags as well
    function spotCenter() { return [BTN_W / 2 + tx * BTN_W * 0.38, BTN_H / 2 + ty * BTN_H * 0.5]; }

07A Button Label Whose Letters Scatter One by One and Regather as Sent

The script splits the label into single letters and scatters them 34ms apart, reading each letter's direction, distance and spin from noise tied to its index, so every letter of Post comment flies its own way, anywhere from 25.3px to 46px, turning up to 40°. The new label Sent comes in from the opposite side along the damped cosine springLeft, overshooting by 25.9% at 186ms and keeping only 0.9% at 520ms, and the pill width rides the same curve while its two round ends move on their own so they do not stretch into ovals. It fits comment posts and feedback sends; the swap time is 140 + characters × 34 + 520, so changing to the four letters of Sent takes 796ms and changing back to the twelve characters of Post comment, space included, takes 1,068ms.

A Button Label Whose Letters Scatter One by One and Regather as Sent
Open in a new tab

07 · A Button Label Whose Letters Scatter One by One and Regather as Sent — Measured area 4.192% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

LETTER_STAGGER_MSSCATTER_AMPREGATHER_MS
    // Remaining displacement ratio — starts at 1 and swings down to 0. One damped sine (cosine) draws the spring solution
    function springLeft(t) {
        if (t <= 0) {
            return 1;
        }
        return Math.exp(-decayRate * t) * Math.cos(omegaD * t);
    }
    // Scatter direction, distance and spin for letter i — with the same seed, the same letter flies the same way
    function scatterOf(i, row) {
        const a = -Math.PI / 2 + noise(i * 0.61, row) * Math.PI * 0.9;
        const d = SCATTER_AMP * (0.55 + 0.45 * (noise(i * 0.61, row + 7.3) * 0.5 + 0.5));
        return [Math.cos(a) * d, Math.sin(a) * d, noise(i * 0.61, row + 13.1) * 40];
    }

08A Button That Reports a Failed Send With a Fading Shake and Turns Into Retry

The first attempt shows sending for 420ms and then fails, and the whole 336px input row shakes by 12·e^(−6τ)·sin(2π·8τ), opening with a first peak of 10.0px and dying out over 4.9 back-and-forth swings in 615ms. The stop time is worked out from the decay rate as the moment the swing width drops below 0.3px, and the send button follows the shake on a spring with stiffness 3600 and damping ratio 0.5, drifting at most 0.62px away from the row. It fits network errors and missing-field alerts, and a second press runs the retry, which succeeds and finishes in mint.

A Button That Reports a Failed Send With a Fading Shake and Turns Into Retry
Open in a new tab

08 · A Button That Reports a Failed Send With a Fading Shake and Turns Into Retry — Measured area 6.092% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

SHAKE_AMP_PXSHAKE_DECAYSHAKE_HZ
    // Starting shake width (px)
    const SHAKE_AMP_PX = 12;
    // Exponential decay rate (1/s) — time for the width to halve = ln2 / SHAKE_DECAY
    const SHAKE_DECAY = 6;
    // Swings per second
    const SHAKE_HZ = 8;
    // Width at which the shake counts as finished (px) — shake only until the width falls below this
    const SHAKE_REST_PX = 0.3;
    const SHAKE_MS = Math.ceil(Math.log(SHAKE_AMP_PX / SHAKE_REST_PX) / SHAKE_DECAY * 1000);
    // Shake formula — a sine wave placed inside an exponentially shrinking envelope. tau is in seconds
    function shakeOffset(tau) {
        return SHAKE_AMP_PX * Math.exp(-SHAKE_DECAY * tau) * Math.sin(2 * Math.PI * SHAKE_HZ * tau);
    }
    function shakeEnvelope(tau) { return Math.exp(-SHAKE_DECAY * tau); }

09A Chat Bubble That Arcs From the Send Button and Lands in the Thread

Pressing measures where the draft text sits in the input (First), attaches the bubble to the thread, measures where it landed (Last), then offsets the bubble back by the difference and releases it along a parabola over 560ms. Vertically it adds 80·4p(1−p) on top of the straight line, exactly 80px higher at the halfway point, and on landing it squashes to 0.78 tall by 1.282 wide to keep its area, turns slightly tall at 84ms and is back to its own shape by 300ms. It fits chat widgets and support windows, and the bubbles already in the thread slide up over 260ms with the same measure-then-invert approach.

A Chat Bubble That Arcs From the Send Button and Lands in the Thread
Open in a new tab

09 · A Chat Bubble That Arcs From the Send Button and Lands in the Thread — Measured area 22.231% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

ARC_HEIGHTFLIP_MSLAND_SQUASH
    // Position during flight — horizontal is linear in time, vertical adds 4h·p(1−p) above the straight line to form a parabola
    function arcOffset(dx, dy, p) {
        return [dx * (1 - p), dy * (1 - p) - ARC_HEIGHT * 4 * p * (1 - p)];
    }
    // Squash at tau (ms) after landing — a damped cosine. Flat at first, slightly tall a third of the way in, 0 at the end
    function squashAt(tau) {
        if (tau < 0 || tau >= SQUASH_MS) {
            return 0;
        }
        return LAND_SQUASH * Math.exp(-5 * tau / SQUASH_MS) * Math.cos(3 * Math.PI * tau / SQUASH_MS);
    }

How Do You Find the Angle a Paper Plane Turns Its Nose Along a Curve?

The flight in 01 runs on two angles. One is the heading, the direction the nose points, and the other is the bank, how far the wings roll to one side.

The heading comes from the tangent of the curve

Differentiating the cubic Bezier position once with respect to its parameter gives the direction of travel at that point, and passing its two screen-plane components to Math.atan2 turns that direction into an angle. On this path the nose starts at +19.7°, reaches its lowest value of −41.9° at curve parameter 0.54, and ends at −10.4°. Progress is raised to the power 1.45 before it is used as the parameter, so the plane leaves slowly and gathers speed, and on the same curve the nose turns gently at first and quickly near the end.

    // Tangent = the Bezier differentiated by t. The angle of its screen-plane components is the heading
    function headingAt(t) {
        const u = 1 - t;
        const c = FLIGHT_CTRL;
        const dx = 3 * u * u * (c[1][0] - c[0][0]) + 6 * u * t * (c[2][0] - c[1][0]) + 3 * t * t * (c[3][0] - c[2][0]);
        const dy = 3 * u * u * (c[1][1] - c[0][1]) + 6 * u * t * (c[2][1] - c[1][1]) + 3 * t * t * (c[3][1] - c[2][1]);
        return Math.atan2(dy, dx);
    }

The bank follows how fast the heading turns

Take the heading at the points 1% before and 1% after on the curve, divide the difference by the time between them, and the result is the turn rate per second; multiplying it by BANK_GAIN 0.3 gives the target bank. The wings do not jump to that target but close 25% of the remaining gap every 16ms step, which keeps them steady where the curve switches the direction it bends. The bank drawn on screen after that smoothing reached, across the 68 steps of the flight, −39.4° at 400ms and +38.7° at 1088ms.

    // Target bank = turn rate × BANK_GAIN. Turn rate is the heading difference between the points 1% either side, divided by the time between them
    function bankTarget(p) {
        const p0 = clamp(p - 0.01, 0, 1);
        const p1 = clamp(p + 0.01, 0, 1);
        let dh = headingAt(flightParam(p1)) - headingAt(flightParam(p0));
        if (dh > Math.PI) {
            dh -= Math.PI * 2;
        }
        if (dh < -Math.PI) {
            dh += Math.PI * 2;
        }
        const rate = dh / ((p1 - p0) * FLIGHT_MS / 1000);
        return clamp(rate * BANK_GAIN, -BANK_MAX, BANK_MAX);
    }

Two different quantities are at work here. The target bank, the value before smoothing, peaks at 41.6° in absolute terms, while the wing angle you see tops out near ±39° because it closes only a quarter of the gap per 16ms step. The BANK_MAX clamp of 0.95rad (54.4°) acts on the target, and on this path it does not engage for a single step: lowering it to 50° to flatten the wings leaves the screen unchanged, and only a value below 41.6° gives the clamp any work to do.

Depth sets both the size and the drawing order

Each facet corner reaches the screen through a perspective projection with a focal length of 620, so at the endpoint, depth 320, it shrinks to 620/940 = 0.66 of its size and lands at screen position (451.1, 68.2). On top of that, the size is reduced separately from 1 to 0.62 during the first 40% of the flight. The four facets are painted from the farthest average depth to the nearest, their brightness comes from the dot product of the facet normal and the light direction, and a facet seen from behind takes the inner paper color.

Double Clicks, Slow Responses, Narrow Screens — Where Send Button Motion Breaks

Each case below was found by following the branches in the source and then confirmed with numbers by calling the functions on their own.

A second press in the middle of the motion

Items 01, 03, 04 and 05 accept a press only in the idle or done state; 06 drops presses while sending, 07 while its label is changing, and 08 while sending or shaking. Two items do not block repeat presses. Item 02 ignores only the repeated keydown events that arrive while a key is held down and otherwise just writes the same label again on each press, which causes no harm. Item 09 launches a new bubble on every press, and once three of your own bubbles (MAX_MINE) are in the thread it removes the oldest one before attaching the next, even if that bubble is still in flight. Wired into a real chat, it needs its own rule for presses that arrive before the request has finished.

A response that arrives faster or slower than the motion

No board sends a network request. The lumpy fill of the ring in 03 is imitated with a seeded random source, and the failure in 08 is decided by nothing more than being the first attempt. Item 01 has to play all of 110 + 780 + 1100 + 320 = 2,310ms before the button returns, so a response that comes back in 0.3 seconds still leaves the user watching a 2.3-second scene. When wiring these up, feed the real progress ratio into 03, attach the failure branch of 08 to the request result, and decide for long scenes like 01 and 05 whether they loop while waiting or play through to the end.

A 16ms step that disagrees with the formula

Solving 02 with the closed-form answer for a damping ratio of 0.45 gives a 20.5% overshoot, while integrating it in fixed 16ms steps gives 17.0%, because a larger step shaves off overshoot. At a damping ratio of 0.2 the two nearly agree, 52.7% from the formula and 52.4% from the steps. Raising stiffness is the riskier direction: while building 08, a button spring at stiffness 6400 and damping ratio 0.6 made the integration blow up, and stiffness 900 with damping ratio 0.3 could not keep up with a row swinging 8 times per second, leaving the button 10px off and stuck near its starting spot.

Coordinates off by a factor of k on a narrow screen

Because the scene is shrunk with scale(k), getBoundingClientRect returns coordinates on the shrunken screen. If centerOf in 09 did not divide those values by k, the distance between the input and the landing spot would be off by a factor of k and the bubble would take off from the wrong place. Each of the nine stylesheets also carries one max-width: 340px media query.

Two facets on the same plane that flicker every frame

The corner triangles folded in 01 end up lying on the same plane as the body, at depth 0. Sorting by average depth alone would swap their order from frame to frame and make them flicker, so the source biases both corner facets by −0.8 to keep them on top of the body. The flap in 05 is the same kind of problem, which is why it swaps its drawing order directly at 90°.

A round cap on a line of zero length

The check in 03 is stroked with stroke-linecap: round, so even a dash of length 0 shows its round end as a single dot. Before drawing starts, its opacity is held at 0 to hide that dot. If you change the ring radius, confirm that the circumference RING_C is still computed from the radius formula, or the end of the ring will stop short or overlap itself.

To set up the cases above yourself by changing the damping or shake constants, open the downloaded archive with 8vmkz6v4. It holds an index.html and style.scss pair for each button, and the constants worth changing are gathered by name near the top of each script.

Spring Damping, Shake Decay and Arc Height to Try, and Accessibility for the Sending State

Name Value Feel
Lower the spring damping 02 SPRING_DAMP 0.45 → 0.2 The overshoot grows from 17.0% (2.0px) to 52.4% (6.3px), the peaks rising more than 0.5px above rest go from 1 to 2, and the time to settle stretches from 0.21s to 0.58s.
Raise the shake decay 08 SHAKE_DECAY 6 → 12 The time for the width to halve drops from 0.116s to 0.058s, the width at 0.3s falls from 17% of the start to 3%, and the shake time shrinks from 615ms to 308ms. The first peak also drops from 10.0px to 8.5px.
Raise the arc height 09 ARC_HEIGHT 80 → 160 The rise above the straight path exactly doubles; on screen the top of the bubble climbs from about 56px to about 135px above the landing spot, and the initial upward speed goes from 0.67 to 1.24px/ms, about 1.9 times.

All nine are a real <button type="button">, so Tab reaches them and Enter or Space presses them. Item 02 also listens to keydown and keyup on its own, so a keyboard press lowers and raises the depth and shadow the same way a finger does. The focus ring is drawn with :focus-visible on all nine, and 03, which shrinks into a circle, draws that ring on its svg body instead of the button box. Items 07, 08 and 09, which end up with only an icon or a changing label, have their aria-label updated by the script as well: 08 reads Send message, then Retry, then Sent, and 09 reads Send: followed by the draft text.

The status line on each board is set to aria-live="off" while the demo runs, so a screen reader does not announce the demo's changes, and it switches to polite on the first press or key from a person. The board root catches that first input in the capture phase, so the demo is cleared before the button's own handler runs.

Each board checks prefers-reduced-motion once, through matchMedia, when it starts. With the setting on, it does not start the frame loop at all and settle() puts the result state in place. A press in that state skips the folding or shaking and jumps to the next result. Item 08 starts out already in its failed state with Retry showing, and the first press takes it to success. Whether a message went out stays readable from text and color without any motion. No board listens for a change to that setting, so switching it while the page is open takes effect after a reload.

Can Paper Plane Animation CSS Rules Alone Produce This Send Effect?

These buttons were not built that way. The stylesheet handles layout, the origin for shrinking the scene, the focus ring and the reduced-motion block, and every moving value is changed by script through transform, opacity, SVG attributes or a canvas. A keyframe plays back a timetable fixed before it starts, but the overshoot in 02 comes from the depth and speed at the moment of release, the highlight in 06 from the current tilt, and the facet order in 01 from that frame's depth. A timetable drawn in advance has no slot to take in those values.

If the plane only needs to fly in a straight line and shrink, transform keyframes and perspective can imitate it. Once the paper has to fold with its faces flipping and its wings have to roll with each bend in the curve, computing inside a requestAnimationFrame loop takes less code. The check length in 03 is measured with getTotalLength, and where that function is missing the script adds up the segment lengths itself.

Neighboring pieces cover related ground: the same spring and progress display on download buttons, the ways a pressed value returns in button press physics, state changes from spinner to success check in button loading states, and canvas bursts from the click point in button click effects.

Questions That Come Up When Wiring a Paper Plane Animation Into a Form

Does 02 actually send a sign-up form, the way most submit button css examples do?

Not as it stands. The button is type="button", so it does not submit a form even inside one, and its click handler only changes the label to Signed up and announces it in the status line. Change type to submit, or send the form yourself inside the submit() function; the press depth and shadow run from pointerdown and keydown on their own, so they stay as they are.

Where do I change 08 so it shakes only when a real request fails?

Inside step(), at the point where the 420ms of sending ends, two lines route to fail() when attempt === 1 and to succeed() otherwise, and that is the demo's result. Replace that condition with the result of your request, and keep phase at sending until the result arrives. The shake width, decay and swing count change through the three constants SHAKE_AMP_PX, SHAKE_DECAY and SHAKE_HZ, and the shake duration is recomputed from those values by itself.

Do the letters in 07 still scatter with a label in another language?

They do. The label is not pre-split in the html; the script breaks it into single characters with Array.from, so it runs with any number of letters, and spaces are swapped for non-breaking spaces so they do not collapse to zero width. The board width of the button is set once to fit the longer of the two labels, so changing the labels also changes the size of the focus ring to match.

Enter the archive password

The password is inside this article. You will find it as you read.