GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

送信ボタン css 9種 — 紙飛行機に折れて飛ぶボタン

送信ボタンは押した後も「送った」を見せる必要があります — 01番は紙飛行機に折れて曲線に沿って傾きながら飛び、02番はばねで沈んで跳ね返り影の高さが追いかけ、08番は失敗すると次第に収まる揺れで知らせます。

九つの送信ボタンを押して、わざと失敗させてみる

自動再生 · タイルを押すとその項目へ · すべて1つのzip

板は人が触るまで、決まった間隔で自分から押されては最初の状態に戻ることをくり返します(02は1.3秒、01・05・06は3秒ごと)。板を一度押すか、板の上でキーを打つと(06は板の上でポインターを動かすだけでも)実演が止まり、ボタンは最初の状態で待つので、そこから先は自分が押したとおりにだけ動きます。08は一回目の送信をわざと失敗として扱ってあるので、一度押して揺れを見てから、もう一度押して再送信が成功するところまで追ってみてください。

送信ボタン css 9種 — 折り・ばね・進行リング・尾・封筒・光沢・文字・揺れ・吹き出し

並び順は、動きがボタンからどこまで広がるかで決めました。01〜03はボタンの体そのものが折れたり沈んだり円に縮んだりし、04・05はボタンから紙飛行機や封筒が離れていきます。06・07は表面の光と文字だけを整え、08・09は入力欄や会話欄まで一緒に動かします。

@keyframes を使わず、16msの刻みで積み上げる

九つのスタイルファイルには@keyframesがひとつもなく、transitionも動きを減らす設定のブロックにあるtransition: none !importantの一行だけです。動きはすべてスクリプトが受け持ちます。requestAnimationFrameが渡す時刻をためておき(一フレームで足す量は100msで打ち切ります)、16msの固定ステップFIXED_DTを一フレームに最大六回回してから一度だけ描きます。ばね・抵抗・慣性のような式は刻みの大きさが変わると結果も変わるので、リフレッシュレートの違う画面でも同じ数字になるよう刻みを固定しました。

480×300の座標で組んだ場面を、箱の幅に合わせて丸ごと縮める

ボタンとその周りの場面は480×300 pxの板の上に置かれ、fitScene()が箱の幅÷480をkとして板全体にscale(k)をかけます。480×300の窓で測ったkは0.8729でした。ポインターの座標や要素の位置を測るときは逆にkで割って場面の座標に戻し、キャンバスを使う01・02・04・05は画素の倍率を2で止めます。

2秒の撮影で見えた九つのボタンの動き

480×300の窓で2秒間に24枚を撮り、隣り合う二枚どうしを比べました。比べた回数は23回なので、動いたフレームは23を分母にしています。

項目 累積面積 動いたフレーム 強度平均
01 紙飛行機の折りと飛行 12.5% 21/23 38.3
02 ばねの沈みと影 9.2% 13/23 50.7
03 円・進行リング・チェック 10.9% 20/23 50.4
04 抵抗とノイズの尾 14.1% 22/23 68.3
05 封筒のふたと封 24.4% 18/23 43.6
06 光沢の帯と傾きの反射光 17.1% 23/23 43.7
07 文字の散りと集まり直し 4.2% 13/23 87.7
08 失敗の揺れ 6.1% 15/23 55.6
09 吹き出しの放物線 22.2% 18/23 72.2

面積が10%に届かないのは02・07・08の三つです。三つともボタンの大きさの中だけで動く部品なので、面積が小さく出るのが正しい結果です。このサイトが基準にしている水滴とブラックホールを描くキャンバスの場面が画面全体を計算で埋めるのに対し、ボタンは小さな面の中の作り込みで測ります — 離した瞬間のばねの行き過ぎ、高さにつれて遠ざかりぼける影、一拍遅れて追う慣性の傾き、面を横切る光沢の帯、文字ごとに違って読まれるノイズの向きがその作り込みです。

送信ボタンはなぜ押された後も動く必要があるのか?

押したボタンがそのままだと、人はまず押せたかどうかを疑って、もう一度押します。お問い合わせフォームの送信、会員登録や決済の確認、コメントの投稿、チャットの入力欄の送信には、どれも押してから結果が届くまでに短いすき間があり、そのすき間に見せられる状態は、送信中・送信済み・送れなかった、の三つです。

この編の九つはその三つを分けて受け持ちます。03の進行リングと06の光沢の帯は送信中を、01・04・05の飛んでいく紙と封筒、07の入れ替わる文字、09の会話欄に着地する吹き出しは送信済みを見せ、08は送れなかったことを揺れで知らせてから再送信へつなぎます。02は結果より前の瞬間、指で押したという手ごたえそのものを受け持ちます。

付ける場所を選ぶときは画面の重さを見ます。決済のように一度だけ押すべき場所には、02の短い反動や03の進行表示のようにボタンの大きさからはみ出さないものが、お問い合わせの受付や招待状のように終わる瞬間そのものがごほうびになる場所には、01・04・05のように場面の大きいものが合います。お問い合わせ後の確認画面にメール 送信 アニメーションをひとつだけ置くなら、手紙が入ってふたが閉じる05が、何が起きたかをいちばん言葉なしで伝えます。

01押すと紙飛行機に折れて曲線に沿って傾きながら飛ぶ送信ボタン

押すと168×52のボタンの面が四枚に分かれて240・240・300msの三段階で折れ、二つの角を折り線のまわりに180°返したあと、上下の半分を真ん中の線のまわりに±64°立てて、52pxあった幅が22.8pxまで狭まったV字の紙飛行機になります。続いて1,100msのあいだ3次ベジェ曲線に沿って奥行き320まで飛びながら機首を曲線の微分の角度に合わせて向け、床の影は奥行きに比例して10pxから35.6pxまで右下へずれ、濃さは0.16から0.064へ薄くなります。お問い合わせフォームのように「送った」をひとつの場面で見せたい所に合い、飛んでいるあいだボタンの位置にはミント色の目印が半透明で敷かれるので、ぽっかり穴があくことはありません。

押すと紙飛行機に折れて曲線に沿って傾きながら飛ぶ送信ボタン
新しいタブで開く

01 · 押すと紙飛行機に折れて曲線に沿って傾きながら飛ぶ送信ボタン — 計測 累積面積 12.511% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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;
    }
    // 床の影 — 同じ面を奥行き z に比例して右下へずらし(10 + 0.08z)薄く塗る。遠ざかるほど影は離れて薄くなる
    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();
        }
    }

02沈んだ深さをばねで戻しながら影の高さも変わる送信ボタン

押しているあいだ目標の深さを12pxへ移し、剛性520・減衰比0.45のばねを16msごとに積分するので、指を離すと休む高さを2.04px(17.0%)行き過ぎてから、208msで±0.5pxの中に入って留まります。影は面の高さhひとつから距離2 + 0.85h・ぼかし4 + 1.6h・濃さを計算し直し、休んでいるときの15.6px・29.6pxが底まで押すと5.4px・10.4pxまで面に寄り添い、ボタンは速さに比例して縦に縮んだぶん横に広がって面積を保ちます。会員登録や決済確認のように「押せた」という手ごたえが大事な場所に合い、クリックが終わると色はそのままで文字だけが「申し込み完了」に変わります。

沈んだ深さをばねで戻しながら影の高さも変わる送信ボタン
新しいタブで開く

02 · 沈んだ深さをばねで戻しながら影の高さも変わる送信ボタン — 計測 累積面積 9.168% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPRING_KSPRING_DAMPSHADOW_LIFT_PX
    // 減衰ばねの1ステップ — 質量1・剛性 stiff・減衰比 zeta。先に速度を変え、その速度で位置を動かす(半陰的オイラー法)
    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;
    }
    // 影の三つの値 — 面の高さ h(px)に比例する。高いほど遠く・ぼかして・薄く
    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); }

03円に縮んだ周りを進行リングが回り、チェックで終わるボタン

押すと192×56の錠剤形が360msかけて中心を保ったまま56×56の円に縮み、その周りに半径36・太さ4のリングを置いて、stroke-dashoffsetを円周226.19 ×(1 − 進み具合)まで減らしながら線を見せていきます。進み具合はシード466の乱数でかたまりごとに増え、リングは残りの差の30%ずつ追いかけ、満ちると160msで色を塗ってから長さ42.46のチェックを300msの等速で引き、折れ曲がる点に99.9msで届きます。保存やフォーム送信を待つ場所に合い、最初の一押しはリングを満たす1,120msを含めて1,952msで完了の状態になります。

円に縮んだ周りを進行リングが回り、チェックで終わるボタン
新しいタブで開く

03 · 円に縮んだ周りを進行リングが回り、チェックで終わるボタン — 計測 累積面積 10.944% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SHRINK_MSRING_STROKECHECK_DRAW_MS
    // 錠剤形の幅 w で胴体を描き直す — 中心を保つには x も一緒に動かす
    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)));
        // 長さ0のダッシュにも丸い端(round cap)が点ひとつ打たれる — 引く前は丸ごと隠す
        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);
    }
    // 後光 — リングが見せる進み具合ひとつで半径(RING_R + 4 → + 38)と濃さを一緒に決める。進行が円の外の広い面までにじむ
    function setHalo(p, a) {
        setNum(halo, 'r', RING_R + 4 + 34 * clamp(p, 0, 1));
        halo.style.opacity = clamp(a, 0, 1).toFixed(3);
    }

04飛んだ跡に空気抵抗とノイズで散っていく尾の粒子

ボタンを離れた紙飛行機が3次ベジェの経路を1,150msかけて通り、ステップごとに粒子を三つずつ進行と逆向きに落とし、粒子の速さはステップごとに0.955を掛けられて241msで半分になります。押す向きはその場所の2D値ノイズが決め、強さは0.06に固定してあるので、一点からまいた100個の粒子が寿命69ステップのあいだに平均62.6px流され、煙のように広がります。ニュースレター登録や招待状の送信に合い、飛行中に207個をまきますが入れ物が150個分しかないため、飛んでいる途中ですでにいっぱいになり、いちばん古い粒子の場所から上書きしていきます。

飛んだ跡に空気抵抗とノイズで散っていく尾の粒子
新しいタブで開く

04 · 飛んだ跡に空気抵抗とノイズで散っていく尾の粒子 — 計測 累積面積 14.133% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TRAIL_COUNTAIR_DRAGTRAIL_NOISE
    // 1ステップ — 速度に抵抗を掛けて弱め、その場所のノイズの値が指す向きへ押してから動かす
    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;
        }
    }

05封筒に変わってふたが折れ、封をされるメール送信ボタン

ボタンが160msで180×112の封筒に広がって手紙を受け取ると、三角のふたが上辺を軸に180°から0°まで480msかけて閉じ、その先端は焦点500の透視で投影されて、90°のとき画面側へ66だけ飛び出します。ふたが動き始めて240msで90°を越える瞬間、内側の面の色で胴体の後ろに描いていたものを外側の面の色でいちばん上に描くよう切り替え、封のシールは初速12を受けたばねで1.41倍までふくらんでから1に落ち着いて貼りつきます。メール送信やお問い合わせ受付完了の画面に合い、一回送るのに2,060msかかります。

封筒に変わってふたが折れ、封をされるメール送信ボタン
新しいタブで開く

05 · 封筒に変わってふたが折れ、封をされるメール送信ボタン — 計測 累積面積 24.408% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FLAP_DEGFLAP_MSSEAL_POP
    // ふたの先端 — 上辺(y = EY)を軸に deg だけ立てる。90° で画面側(z が負)へいちばん飛び出す
    function flapTip(deg) {
        const a = deg * Math.PI / 180;
        return [ECX, EY + FLAP_H * Math.cos(a), -FLAP_H * Math.sin(a)];
    }
    // 90° を越えたか — 越えるとふたの内側の面が見え、胴体の後ろに描く
    function flapInsideVisible(deg) { return deg > 90; }
    function flapDegAt(t) { return FLAP_DEG * (1 - easeInOut(t / FLAP_MS)); }

06送信中に光沢の帯が通り、ポインターの傾きに沿って反射光が動くボタン

送信中の1,700msのあいだ、幅46の光沢の帯がskewX(-22deg)で傾いたまま820msごとに−92pxから278pxまでボタンを横切り、ボタンはポインターへ残りの距離の12%ずつ近づく傾きを受けて最大14°回ります。傾きの目標はボタンの中心から見たポインターの位置を(幅の半分 + 40、高さの半分 + 60)で割って正規化した値で、ポインターが離れると0に戻り、反射光の中心はポインターの座標ではなくこの遅れた傾きから計算し直すので、実演のポインターを追うとき傾きが最大2.8°遅れても光が同じだけ遅れて二つがずれません。購入やプレミアム申し込みのように目を引きたいボタンに合い、目標の傾きの90%まで近づくのに304msかかります。

送信中に光沢の帯が通り、ポインターの傾きに沿って反射光が動くボタン
新しいタブで開く

06 · 送信中に光沢の帯が通り、ポインターの傾きに沿って反射光が動くボタン — 計測 累積面積 17.132% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SWEEP_MSGLOSS_WIDTHTILT_LERP
    // 慣性 lerp の1ステップ — 目標へまっすぐ行かず、残りの距離の TILT_LERP 分だけ進む
    function follow(cur, goal) { return cur + (goal - cur) * TILT_LERP; }
    // 光沢の帯の x — 一回通るあいだに左の外(−2×幅)から右の外(ボタンの幅 + 幅)まで
    function glossX(t) {
        const p = (t % SWEEP_MS) / SWEEP_MS;
        return lerp(-GLOSS_WIDTH * 2, BTN_W + GLOSS_WIDTH, easeInOut(p));
    }
    // ハイライトの中心 — ポインターではなく今の傾き(tx, ty)から計算し直す。だから反射光も遅れて追ってくる
    function spotCenter() { return [BTN_W / 2 + tx * BTN_W * 0.38, BTN_H / 2 + ty * BTN_H * 0.5]; }

07文字がひとつずつ散ってから「送信済み」に集まり直すボタンの文字

スクリプトが文言を一文字ずつ分けて34ms間隔で散らし、向き・距離・回転を文字の番号に結びついたノイズから読むので、「コメントを送る」の各文字はSCATTER_AMP46の0.55倍から1倍まで、式の上では25.3〜46pxの範囲で、それぞれ違う方へ飛んでいきます。新しい文言「送信済み」は減衰コサインのspringLeftに沿って反対側から入り、186msで25.9%行き過ぎてから520msで0.9%だけを残し、錠剤形の幅も二つの文言の幅のあいだを同じ曲線で移りますが、両端の半円は別に動かすので楕円には伸びません。コメントの投稿やフィードバックの送信に合い、四文字の「送信済み」へ替わるのに796ms、七文字の「コメントを送る」へ戻るときは898msかかります(計算値、140 + 文字数×34 + 520)。

文字がひとつずつ散ってから「送信済み」に集まり直すボタンの文字
新しいタブで開く

07 · 文字がひとつずつ散ってから「送信済み」に集まり直すボタンの文字 — 計測 累積面積 4.192% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LETTER_STAGGER_MSSCATTER_AMPREGATHER_MS
    // 残りの変位の割合 — 1から始まり振動しながら0へ。減衰サイン(コサイン)ひとつでばねの解を描く
    function springLeft(t) {
        if (t <= 0) {
            return 1;
        }
        return Math.exp(-decayRate * t) * Math.cos(omegaD * t);
    }
    // 文字 i が散る向き・距離・回転 — 同じシードなら同じ文字は同じ方へ飛ぶ
    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];
    }

08送信に失敗すると次第に収まる揺れで知らせ、再送信に変わるボタン

一回目の送信は420msのあいだ送信中を見せてから失敗に変わり、336pxの入力欄全体が12·e^(−6τ)·sin(2π·8τ)で揺れて、最初の頂点10.0pxから始まり615msのあいだに4.9往復しながら収まっていきます。揺らすのをやめる時刻は、幅が0.3pxを下回る時点を減衰率から逆算して決め、送信ボタンはその揺れを剛性3600・減衰比0.5のばねで追うので、入力欄とのずれは最大0.62pxにとどまります。ネットワークエラーや入力漏れを知らせる場面に合い、もう一度押すと再送信が成功してミント色で終わります。

送信に失敗すると次第に収まる揺れで知らせ、再送信に変わるボタン
新しいタブで開く

08 · 送信に失敗すると次第に収まる揺れで知らせ、再送信に変わるボタン — 計測 累積面積 6.092% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SHAKE_AMP_PXSHAKE_DECAYSHAKE_HZ
    // 最初の揺れ幅(px)
    const SHAKE_AMP_PX = 12;
    // 指数の減衰率(1/s) — 幅が半分になる時間 = ln2 / SHAKE_DECAY
    const SHAKE_DECAY = 6;
    // 一秒あたりの往復数
    const SHAKE_HZ = 8;
    // 揺れが終わったとみなす幅(px) — これを下回る時刻までだけ揺らす
    const SHAKE_REST_PX = 0.3;
    const SHAKE_MS = Math.ceil(Math.log(SHAKE_AMP_PX / SHAKE_REST_PX) / SHAKE_DECAY * 1000);
    // 揺れの式 — サイン波を指数で小さくなる包絡線の中に入れる。tau は秒
    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); }

09チャットの送信ボタンから吹き出しが放物線を描いて会話欄に着地する場面

押すと入力欄の文字の位置(First)を測り、吹き出しを会話欄に付けてから付いた位置(Last)を測り直し、その差だけ逆向きにずらしておいた吹き出しを560msかけて放物線でほどきながら着地させます。縦方向には直線の上に80·4p(1−p)を足すので進行の半分で直線よりちょうど80px高くなり、着地の瞬間は縦0.78・横1.282で面積を保ったまま平たくなってから、84msで少し縦長になり、300msで元の形に戻ります。チャットウィジェットやサポート窓に合い、先に来ていた吹き出しも同じ測り方で260msかけて押し上げられます。

チャットの送信ボタンから吹き出しが放物線を描いて会話欄に着地する場面
新しいタブで開く

09 · チャットの送信ボタンから吹き出しが放物線を描いて会話欄に着地する場面 — 計測 累積面積 22.231% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ARC_HEIGHTFLIP_MSLAND_SQUASH
    // 飛んでいるあいだの位置 — 横は時間に正比例、縦は直線の上に 4h·p(1−p) を足して放物線になる
    function arcOffset(dx, dy, p) {
        return [dx * (1 - p), dy * (1 - p) - ARC_HEIGHT * 4 * p * (1 - p)];
    }
    // 着地後 tau(ms)の平たさ — 減衰コサイン。最初は平たく、1/3 の地点で少し縦長、最後に0
    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);
    }

紙飛行機が曲線の上で機首を向ける角度はどう求めるのか

01の飛行は二つの角度で動きます。ひとつは機首が向く方向、もうひとつは翼が横に寝る傾きです。

機首の向きは、曲線を微分した接線から出ます

3次ベジェの位置の式を媒介変数で一度微分するとその地点の進む方向が出て、画面の平面にある二つの成分をMath.atan2に入れると角度になります。この経路で機首は出発時に+19.7°、媒介変数0.54で最も小さい−41.9°、終点で−10.4°です。出だしがゆっくりで後ほど速くなるよう、進み具合を1.45乗して媒介変数に使うため、同じ曲線でも前半では機首がゆっくり、後半では速く回ります。

    // 接線 = ベジェを t で微分したもの。画面の平面成分の角度が機首の向き
    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);
    }

翼の傾きは、機首の回る速さに比例させます

曲線上で前後1%の地点の機首の角度差を、そのあいだの時間で割ると一秒あたりの旋回率になり、これにBANK_GAINの0.3を掛けた値が目標の傾きです。翼は目標へまっすぐ向かわず、16msのステップごとに残りの差の25%ずつだけ近づくので、曲線の曲がる向きが入れ替わる区間でも跳ねません。こうして追った後に画面に描かれた傾きは、飛行68ステップのあいだに400msで−39.4°、1088msで+38.7°まで行きました。

    // 目標の傾き = 旋回率 × BANK_GAIN。旋回率は前後1%の地点の機首の角度差をそのあいだの時間で割った値
    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);
    }

BANK_MAXの0.95rad(54.4°)のクランプは、この経路では一ステップもかかりません。目標の傾きは左右どちらに振れたときも、いちばん大きくて41.6°だからです。画面に出た±39°前後の値は目標を25%ずつ追った後の傾きで、クランプが比べる目標の値とは別の量です。翼を寝かせすぎないようこの上限を50°に下げても画面は変わらず、41.6°より下にして初めてクランプが働きます。

遠いほど小さく、重なる順も奥行きで決める

面の頂点は焦点距離620の透視投影で画面に移され、奥行き320の終点では620/940 = 0.66倍に縮み、画面座標(451.1、68.2)に届きます。大きさはこれとは別に、飛行の最初の40%で1から0.62へ縮めます。四枚の面は平均の奥行きが遠いものから塗り、法線と光の向きの内積で明るさを決め、裏返って見える面は紙の内側の色で塗ります。

二度押し・遅い応答・狭い画面 — 送信ボタンの演出が壊れる瞬間

ここに書いた場面は、ソースの分岐を追って読み、関数を単独で呼んで数字で確かめ直したものです。

動いている途中でもう一度押したとき

01・03・04・05は最初の状態か完了の状態でなければ押されても受け付けず、06は送信中に、07は文字の入れ替え中に、08は送信中と揺れている最中に押されたことを捨てます。止めていないのは二つです。02は押し下げたまま続けて届くkeydownのくり返しだけを無視し、押すたびに同じ文字を書き直すだけなので困りませんが、09は押すたびに新しい吹き出しを飛ばし、自分の吹き出しがすでに三つ(MAX_MINE)あると、四つ目を付ける前にいちばん古いものを飛んでいる途中でも外してしまいます。本物のチャットに付けるなら、リクエストが終わる前の連打をどう扱うかを別に決める必要があります。

応答が演出より速いとき、遅いとき

九つの板のどこにもネットワークリクエストはありません。03のリングがかたまりごとに満ちる様子はシード付きの乱数でまねたもので、08の失敗は一回目だからという決まった結果です。01は一度押すと110 + 780 + 1100 + 320 = 2,310msを回りきらないとボタンが戻らないので、応答が0.3秒で返ってきても利用者は2.3秒の場面を見ることになります。付けるときは03の進み具合の場所に本当の進捗の割合を入れ、08は失敗の分岐をリクエストの結果につなぎ、01・05のような長い場面は応答を待つあいだくり返すか、最後まで見せるかを決めます。

16msの刻みが公式と違う値を出す

02を減衰比0.45の連続解の公式で計算すると行き過ぎは20.5%ですが、16msの固定ステップで積分すると17.0%になります。刻みが大きいほど行き過ぎを削るためです。減衰比0.2では公式52.7%・積分52.4%で、二つはほぼ同じです。剛性を大きく上げるほうがもっと危うく、08を作るときボタンのばねを剛性6400・減衰比0.6にすると積分が発散し、剛性900・減衰比0.3では毎秒8回揺れる入力欄に追いつけず、ボタンが10pxずれたまま取り残されました。

狭い画面で座標がk倍ずれる

場面をscale(k)で縮めているので、getBoundingClientRectが返す値は縮んだ後の画面座標です。09のcenterOfがこの値をkで割らないと、入力欄と着地位置の距離がk倍狂い、吹き出しが見当違いの場所から飛び立ちます。九つのスタイルにはそれぞれmax-width: 340pxのメディアクエリがひとつずつ入っています。

同じ平面の二枚がフレームごとにちらつく

01で折られる角の三角形は、折り終わると胴体と同じ平面(奥行き0)に寝ます。平均の奥行きだけで順番を決めると二枚の順がフレームごとに入れ替わってちらつくので、ソースは角の二枚に−0.8を足して胴体の上に来るようにしました。05のふたも同じ種類の問題で、90°を境に描く順番を直接入れ替えています。

長さ0の線にも丸い端が打たれる

03のチェックはstroke-linecap: roundで引くため、まだ長さ0のダッシュにも丸い端が点ひとつとして見えていました。引き始める前はopacityを0にして隠しています。リングの半径を変えるときは、円周RING_Cが半径の式から計算し直されているかを確かめないと、リングの端が閉じきらなかったり重なったりします。

ばねの減衰比や揺れの定数を書き換えて上の場面を自分で起こしてみるなら、ファイル一式の圧縮ファイルはパスワード 8vmkz6v4 で開けます。ボタンごとにindex.htmlとstyle.scssが一組ずつあり、変える定数はそれぞれのスクリプトの上のほうに名前を付けてまとめてあります。

ばねの減衰・揺れの収まる速さ・放物線の高さを動かす試しと、送信中の状態のアクセシビリティ

名前 値 変わり方
ばねの減衰を下げる 02 SPRING_DAMP 0.45 → 0.2 行き過ぎが17.0%(2.0px)から52.4%(6.3px)に大きくなり、休む高さより0.5px以上上がる頂点が1回から2回に増え、留まるまでの時間が0.21秒から0.58秒に延びます。
揺れの収まる速さを上げる 08 SHAKE_DECAY 6 → 12 幅が半分になる時間が0.116秒から0.058秒に、0.3秒時点の幅が最初の17%から3%に、揺らす時間が615msから308msに縮みます。最初の頂点も10.0pxから8.5pxになります。
放物線の高さを上げる 09 ARC_HEIGHT 80 → 160 直線より上がる高さがちょうど2倍になり、画面では吹き出しの頂点が着地位置の約56px上から約135px上へ、最初に上がる速さが0.67から1.24px/msへ約1.9倍になります。

Tabで届き、Enter・Spaceで押せる本物のボタン

九つとも本物の<button type="button">なので、Tabで届き、Enter・Spaceで押せます。02はその上でkeydown・keyupを別に受け取り、キーボードで押しても深さと影が手で押したときと同じように沈んで戻ります。フォーカスの枠は九つとも:focus-visibleで描き、円に縮む03はその枠をボタンの箱ではなくsvgの胴体に描きます。文字のないアイコンや入れ替わる文言だけが残る07・08・09はスクリプトがaria-labelも一緒に書き換え、08は「メッセージを送る」から「再送信」、「送信済み」へ、09は送信:の後に入力した文言を付けて読み上げられます。

実演のあいだは黙っている状態表示

板ごとの状態表示は実演が回っているあいだaria-live="off"なので、画面読み上げソフトが実演の変化を読むことはなく、人が初めて押すかキーを打つとpoliteに変わります。この最初の入力はルートがキャプチャ段階で受け取るので、ボタン自身の処理より先に実演を片付けます。

動きを減らす設定では、結果の状態を先に立てる

prefers-reduced-motionがオンの画面では、九つの板がフレームのループをそもそも始めず、settle()で結果の状態をそのまま立てます。この設定はページを開いたときにmatchMediaで一度読むだけでchangeを聞く仕組みはないので、開いたまま設定を切り替えた場合は再読み込みした後に反映されます。その状態でボタンを押すと、折れたり揺れたりする過程を飛ばして次の結果へ進み、08は最初から失敗と再送信の状態で立っていて、一度押すと成功の状態になります。送れたかどうかは、動きがなくても文字と色で残ります。

ボタン アニメーション サンプルの css だけで紙飛行機の送信効果はできるか?

この編のボタンはそういう作り方をしていません。スタイルファイルが受け持つのは配置、板を縮めるときの基準点、フォーカスの枠、動きを減らす設定のブロックで、動く値はすべてスクリプトがtransform・opacity・SVGの属性・キャンバスで変えています。キーフレームは始まる前に決めた時間割をそのまま再生しますが、02の行き過ぎは指を離した瞬間の深さと速さから、06の反射光はその時点の傾きから、01の面の順番はそのフレームの奥行きから決まります。前もって描いた時間割には、これらの値を受け取る場所がありません。

紙飛行機がまっすぐ飛びながら小さくなる程度なら、transformのキーフレームとperspectiveでもまねできます。ただ、紙が折れて面の表と裏が入れ替わり、曲線の曲がりに合わせて翼が寝る様子まで求めるなら、requestAnimationFrameのループの中で計算するほうが短く済みます。03のチェックの長さはgetTotalLengthで測り、この関数がない環境では線分の長さを自分で足します。

色や角丸をスタイルだけで決める css 送信ボタン デザインとは違い、キャンバスに塗る01・02・04・05とSVGの塗りを切り替える03では色もスクリプトの中の値なので、手持ちのボタンの見た目に合わせるときはスタイルファイルとスクリプトの両方を見ます。乱数を使う03・04・07はmakeRngをシード付きで呼ぶため、ページを開くたびに同じ並びで始まります。makeRngは九つのファイルすべてに定義が置いてありますが、残りの六つは呼んでいません。

ダウンロードボタンで同じばねと進行表示を扱ったダウンロードボタンの9種、押し下げた値の戻り方だけを集めたボタンの押し込み9種、スピナーから成功のチェックまで状態を切り替えるボタンのローディング9種、クリックした点からキャンバスで広がるボタンのクリック効果9種が近い編です。

紙飛行機 アニメーションをフォームにつなぐときによくある疑問

会員登録フォームの送信ボタンに02を付けると、フォームは本当に送られますか?

このままでは送られません。ボタンがtype="button"なのでフォームの中に入れても送信されず、clickの処理は文字を「申し込み完了」に変えて状態表示に知らせるだけです。typeをsubmitに変えるか、submit()関数の中でフォームを直接送ればよく、押し下げの深さと影はpointerdown・keydownで別に回っているのでそのまま残ります。

本物のリクエストが失敗したときだけ08を揺らすには、どこを変えますか?

step()の中で420msの送信中が終わるとき、attempt === 1ならfail()、そうでなければsucceed()へ進む二行が実演用の結果です。この条件をリクエストの結果に置き換え、結果が届くまではphaseをsendingにとどめておけば済みます。揺れの幅・減衰・振動数はSHAKE_AMP_PX・SHAKE_DECAY・SHAKE_HZの三つの定数を変えるだけで、揺らす時間はその値から自動で計算し直されます。

送信 中 アニメーションの文言を日本語に変えても、07の文字はばらばらに散りますか?

散ります。文言をhtmlの中で先に分けておかず、スクリプトがArray.fromで一文字ずつ分けるので、文字数が違ってもそのまま動き、空白は幅が0にならないように改行しないスペースに置き換えて入れます。このページの07が「コメントを送る」の七文字で動いているのもこの仕組みによるもので、ボタンの板の幅は二つの文言のうち長いほうに合わせて一度決めるため、文言を変えるとその長さに応じてフォーカスの枠の大きさも変わります。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。