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

モーダル アニメーション 9種 — ばね・円マスク・画素で開閉

開くときは凝ったモーダルも、閉じるときはたいていただ消えます。ここでは03番がいちばん遠い角まで測った円で窓を開き、05番はマスごとに違うノイズのしきい値を越えた順に画素が現れ、07番は行ごとに幅を狭めて一点へ吸い込まれて閉じます。

九通りに開いて閉じるモーダルの舞台

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

どのマスも2,400msで一周します。窓は200msで開きはじめ、閉じる側へ移る時刻は項目ごとに決めてあって、01は1,500ms、06は1,850ms、07と08は1,150msです。07は「カートに入れる」、08は「削除」を押した扱いで閉じるのでカートへ吸い込まれたり砕けたりし、09は900msに「確定する」を押した瞬間から押し込み・チェック・縮小が続きます。

モーダル アニメーション 9種 — ばね・ボタン位置から拡大・円マスク・紙折り・画素ディゾルブ・波紋の屈折・漏斗で閉じる・ガラスの破片・決済確認の流れ

01〜06は開き方で、07と08は閉じ方で見せる項目です。09だけは、開いて押して閉じるまでの一周を一枚の窓に詰めました。スタイルシートに@keyframesは一行もなく、requestAnimationFrameが渡す経過時間を16msの固定ティックに区切って、ティックごとにばね・補間・ノイズ・波・破片の物理を解くループが動きを受け持ちます。閉じる動きも開く動きの逆再生ではなく、項目ごとに別の計算です。

描くのはキャンバス、押されるのは透明なDOM

この舞台で目に見える窓・ボタン・見出し・本文の文字は、480×300の場面座標に合わせてキャンバスがティックごとに描いた絵です。操作と読み上げを受け持つのは別の層で、role="dialog"・aria-modal="true"・aria-labelledby・aria-describedbyを持つダイアログ、開くボタン、窓の中の二つのボタンが、color: transparentのDOMとして同じ位置に重なっています。キャンバスにはaria-hidden="true"が付いているので、スクリーンリーダーに届くのはDOMの文字だけです。

窓を切ったり伸ばしたりできるのは、makeSpriteが画面外のキャンバス3枚に窓の絵を先に写しておくからです。背景まで描いた版、背景を抜いた版、確認ボタンを抜いた版があり、効果はその切れ端をdrawImageで描き移します。getImageDataで画素を読む処理は九つのソースに一度も出てきません。ただ、絵の文字とDOMの文字が分かれる作りなので、実際のサービスでは同じ計算をDOMのtransformやclip-pathに渡すほうが扱いやすいと考えられます。この点はよくある質問の節で具体的に見ます。

キャプチャ24枚で測った九つの窓の動き

キャプチャ24枚のあいだにある23回の変化のうち、画面が変わった回数をフレームの列に入れました。

窓 累積面積 動いたフレーム 強度平均
01 ばねで跳ね上がる 74.8% 12/23 46.8
02 ボタン位置から拡大 75.8% 13/23 55.6
03 円マスク 67.1% 15/23 94.2
04 紙折り 75.8% 16/23 50.9
05 画素ディゾルブ 75.5% 17/23 36.9
06 波紋の屈折 75.9% 21/23 34.4
07 漏斗で閉じる 75.9% 20/23 41.4
08 ガラスの破片 75.9% 17/23 45.8
09 決済確認の流れ 75.9% 19/23 47.1

累積面積が九つとも67〜76%に達しているのは、窓そのものが広く動いたからではありません。窓が開くと背後のページを覆う暗い幕(不透明度は最大0.46)が舞台全体に敷かれては引くので、幕が通った画素がすべて変化として数えられます。03だけが67.1%と低いのは、幕も窓も円の内側にしか塗らない場面だからと考えられます。フレーム数が最少の01(12/23)は、ばねが480msごろに落ち着いてから閉じるまで画面が止まっているためで、06が21/23まで上がったのは波紋が1,504msまで静まりながら画面を揺らし続けるためと考えられます。

会員登録の案内から注文の確定まで、こんな窓に載せる

九つの窓には、それぞれ実際のサービスの一場面を割り当てました。01は会員登録の案内、02は商品一覧のクイックビュー、03は通知バッジから開く通知の詳細、04は利用規約への同意、05はクーポン当選のお知らせ、06は夏の特集のお知らせ、07はオプションを選んでカートに入れる窓、08はファイル削除の確認、09は注文の最終確認です。09の画面には支払い方法も金額も出てこない、架空の注文です。

モーダル 表示 アニメーションを任されると、窓の中身にくらべて、窓が現れて消える瞬間は後回しになりがちです。同じ確認の窓でも、何を確認したかで閉じ方を変えたい場面があります。07は入れた品物がカートへ向かう道を、08は消したファイルが戻らないことを、閉じる動きそのもので伝えます。08を「キャンセル」で閉じたときに砕けないのもそのためです。

01ばねで跳ね上がり影が深まるモーダル

窓は0.55倍の大きさから、剛性150・減衰14のばねを16msごとに積分した値で広がります。減衰比が0.57なので大きさは一度104.1%までふくらんでから戻り、480ms以降は目標の1%以内に収まります。浮き上がりの高さを大きさに比例させ、影のぼかしと下への距離を一緒に変えているため、行き過ぎた瞬間の浮き上がりは28.4pxとSHADOW_LIFT_MAXの26を超え、気軽に誘う会員登録の案内ウィンドウに向いています。

測定値は累積面積74.8%、動いたフレーム12/23、強度平均46.8でした。

ばねで跳ね上がり影が深まるモーダル
新しいタブで開く

01 · ばねで跳ね上がり影が深まるモーダル — 計測 累積面積 74.769% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPRING_STIFFNESSSPRING_DAMPINGSHADOW_LIFT_MAX
    // 減衰ばねの1ステップ — 加速度 = -剛性 x (位置 - 目標) - 減衰 x 速度。先に速度を直し、その速度で位置を動かす。dt は秒
    function springStep(s, target, stiffness, damping, dt) {
        const a = -stiffness * (s.x - target) - damping * s.v;
        s.v += a * dt;
        s.x += s.v * dt;
    }
    function springRest(s, target) { return Math.abs(s.x - target) < 0.002 && Math.abs(s.v) < 0.02; }
    // 大きさ — ばねの値が 1 を超えると目標より大きくなる(行き過ぎ)
    function popScale() { return SCALE_FROM + (1 - SCALE_FROM) * pop.x; }
    // 浮き上がった高さ — 大きさに比例する。行き過ぎた瞬間は目標の高さよりさらに浮き、影がより深く遠くなる
    function popLift() { return SHADOW_LIFT_MAX * Math.max(0, (popScale() - SCALE_FROM) / (1 - SCALE_FROM)); }
    function itemPage() { drawOpener(1); }
    function itemDraw() {
        if (pop.x <= 0.001) {
            return;
        }
        drawScrim(pop.x);
        drawPanelScaled(spriteFull, popScale(), pop.x * 1.8, popLift());
    }

02ボタンの位置から育って窓になるモーダル

128×34の「クイックビュー」ボタンの矩形から272×172の窓の矩形まで、位置・幅・高さを520msかけて同時に補間します。角の半径は17から14へ縮め、伸びた座標系に合わせて倍率で割った値を渡すので、伸びている途中でも角は丸いままです。中の文字は親の倍率の逆数で描き戻していて、始まった瞬間のその値は横2.125・縦5.059になり、商品一覧で押したボタンがそのままクイックビューへ広がる流れに合います。

この窓は75.8%を覆い、13/23フレームで強度平均55.6でした。

ボタンの位置から育って窓になるモーダル
新しいタブで開く

02 · ボタンの位置から育って窓になるモーダル — 計測 累積面積 75.818% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GROW_DURATION_MSCORNER_RADIUS_FROMCORNER_RADIUS_TO
    function itemDraw() {
        if (grow <= 0) {
            return;
        }
        const e = easeInOutCubic(grow);
        drawScrim(e);
        const r = growRect(e);
        const rad = lerp(CORNER_RADIUS_FROM, CORNER_RADIUS_TO, e);
        const sx = r.w / PANEL.w;
        const sy = r.h / PANEL.h;
        ctx.save();
        ctx.translate(r.x, r.y);
        // 親の層 — 窓全体を sx, sy で伸ばした座標系(DOM なら窓に transform: scale(sx, sy) の一行)
        ctx.scale(sx, sy);
        // 半径を倍率で割って渡す — 伸びたあとも角が半径 rad の円に見える
        roundRectPath(ctx, 0, 0, PANEL.w, PANEL.h, rad / sx, rad / sy);
        ctx.shadowColor = rgba(SCRIM, 0.16 + 0.18 * e);
        ctx.shadowBlur = (6 + 24 * e) * dpr;
        ctx.shadowOffsetY = (3 + 10 * e) * dpr;
        ctx.fillStyle = rgba(mix(ACCENT, PANEL_BG, smooth(e * 1.6)), 1);
        ctx.fill();
        ctx.shadowColor = 'rgba(0,0,0,0)';
        ctx.clip();
        // 子の層 — 逆比率 1/sx, 1/sy で戻し、中の文字が伸びて見えないようにする
        ctx.scale(1 / sx, 1 / sy);

03いちばん遠い角まで測った円で開くモーダル

円の中心は開くボタンの中央(396, 265)で、舞台の四隅までの距離476.5・278・397.5・91pxのうち最大の左上476.5pxを最後の半径にし、700msのあいだ減速する曲線で広げます。幕と窓は円の内側だけに描かれるので、通知バッジを押した場所から詳細がにじみ出る画面に使いやすく、閉じるときは同じ曲線を逆にたどって終わりほど速くなります。

累積面積は九つで最小の67.1%、強度平均は最大の94.2で、変化したフレームは15/23でした。

いちばん遠い角まで測った円で開くモーダル
新しいタブで開く

03 · いちばん遠い角まで測った円で開くモーダル — 計測 累積面積 67.108% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

IRIS_ORIGIN_XIRIS_ORIGIN_YIRIS_DURATION_MS
    // 最後の半径 — 始点から舞台の四隅までの距離のうち最大の値。この半径なら円が舞台をすき間なく覆う
    function irisMaxRadius(ox, oy) {
        const corners = [[0, 0], [W, 0], [0, H], [W, H]];
        let m = 0;
        for (let i = 0; i < corners.length; i += 1) {
            m = Math.max(m, Math.hypot(corners[i][0] - ox, corners[i][1] - oy));
        }
        return m;
    }
    const IRIS_X = IRIS_ORIGIN_X * W;
    const IRIS_Y = IRIS_ORIGIN_Y * H;
    const IRIS_R = irisMaxRadius(IRIS_X, IRIS_Y);

04折れた紙が広がるように三枚で開くモーダル

窓を高さ57.33pxの板3枚に分け、84°に折れた状態から760msで平らに広げます。板は2pxの帯に切り、帯の奥行きに応じた遠近の倍率(いちばん奥の端で0.901)を掛けて描きます。明るさは板の法線と光の方向がなす角のコサインで決まり、光に背を向けた板が0.42から42°の時点で0.672へ明るくなる間に光の側の板は1.03のハイライトを受けるので、最後まで読んで同意する利用規約の窓のように紙らしさが似合う場所に向いています。

キャプチャでは累積面積75.8%、16/23フレーム、強度平均50.9でした。

折れた紙が広がるように三枚で開くモーダル
新しいタブで開く

04 · 折れた紙が広がるように三枚で開くモーダル — 計測 累積面積 75.815% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FOLD_PANELSFOLD_START_DEGFOLD_SHADE_MIN
    // 今の板の角度(ラジアン)
    function foldAngle() { return FOLD_START_DEG * (1 - easeInOutCubic(fold)) * Math.PI / 180; }
    // 板の明るさ — 板の法線と光の方向がなす角のコサイン。まっすぐ立てば 1、光に背を向けて寝るほど FOLD_SHADE_MIN の方へ暗くなる。
    // 1 を超える分(光の側へ傾いた板)はハイライトとして返す
    function foldShade(th, dir) {
        const flat = -LIGHT_Z;
        const lit = (dir * Math.sin(th) * LIGHT_Y - Math.cos(th) * LIGHT_Z) / flat;
        return FOLD_SHADE_MIN + (1 - FOLD_SHADE_MIN) * Math.max(0, lit);
    }

05マスごとに違うノイズのしきい値で画素が現れるモーダル

窓を6pxのマス46×29 = 1,334個に分け、シード4735の値ノイズを二重に重ねてマスごとのしきい値を決めます。しきい値は読み込み時にbuildThresholds()で一度だけ作り、800msの進行率がしきい値を越えたマスだけを窓の絵から切り取って描き、越えたばかりのマスは光の帯の色で塗り重ねます。しきい値の散らばりが均等ではないので進行25%では18.4%しか見えず、50%で66.5%が現れる立ち上がり方は、何が当たったかを少し待たせたいクーポン当選のお知らせに合います。

画面の変化は17/23フレーム、累積面積75.5%、強度平均36.9でした。

マスごとに違うノイズのしきい値で画素が現れるモーダル
新しいタブで開く

05 · マスごとに違うノイズのしきい値で画素が現れるモーダル — 計測 累積面積 75.538% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DISSOLVE_CELL_PXNOISE_SCALEDISSOLVE_DURATION_MS
    // マスごとのしきい値 — 二重のノイズ(周波数 x2 は強さ x0.5)を足したあと、0 から (1 - 光の幅) の間に引き伸ばす。
    // そのため進行率 1 では最後のマスまで光の帯を過ぎ、窓がくっきり残る
    function buildThresholds() {
        const rng = makeRng(4735);
        const lw = Math.ceil(PANEL.w * NOISE_SCALE * 2) + 3;
        const lh = Math.ceil(PANEL.h * NOISE_SCALE * 2) + 3;
        const lattice = [];
        for (let i = 0; i < lw * lh; i += 1) {
            lattice.push(rng());
        }
        const raw = [];
        let lo = 1e9;
        let hi = -1e9;
        for (let r = 0; r < DISSOLVE_ROWS; r += 1) {
            for (let c = 0; c < DISSOLVE_COLS; c += 1) {
                const px = (c + 0.5) * DISSOLVE_CELL_PX;
                const py = (r + 0.5) * DISSOLVE_CELL_PX;
                const v = valueNoise(lattice, lw, px * NOISE_SCALE, py * NOISE_SCALE)
                    + 0.5 * valueNoise(lattice, lw, px * NOISE_SCALE * 2, py * NOISE_SCALE * 2);
                raw.push(v);
                lo = Math.min(lo, v);
                hi = Math.max(hi, v);
            }
        }
        return raw.map(function (v) { return ((v - lo) / Math.max(hi - lo, 1e-6)) * (1 - DISSOLVE_EDGE); });
    }

06波紋が広がり揺らいでいた窓が澄んでいくモーダル

4pxのマス68×43の高さの格子で波動方程式を16msごとに解き、中央のしずく一つと小さなしずく二つを広げます。マスごとに高さの傾きへ0.08を掛けた分だけずらした位置から窓の絵を持ってくるのが屈折で、ずれは最大15.0pxです。いちばん高い波がしきい値6を下回る1,504msで開き終わり、状態表示はその手前、開いている途中でrippleAlpha === 1のときだけ「波紋が静まっています」になるので、季節の空気を出したい夏の特集のお知らせに使えます。

九つのうち最も長い21/23フレーム動き、累積面積75.9%、強度平均34.4でした。

波紋が広がり揺らいでいた窓が澄んでいくモーダル
新しいタブで開く

06 · 波紋が広がり揺らいでいた窓が澄んでいくモーダル — 計測 累積面積 75.897% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RIPPLE_DAMPINGRIPPLE_STRENGTHREFRACT_SCALE
    // 波動方程式の1ステップ — 次の高さ = (隣り四つの和 / 2 - 前の高さ) x 減衰。端のマスは 0 に固定し、波が壁で跳ね返る
    function waveStep() {
        const c = waveCur;
        const p = wavePrev;
        for (let y = 1; y < RIPPLE_ROWS - 1; y += 1) {
            for (let x = 1; x < RIPPLE_COLS - 1; x += 1) {
                const i = y * RIPPLE_COLS + x;
                p[i] = ((c[i - 1] + c[i + 1] + c[i - RIPPLE_COLS] + c[i + RIPPLE_COLS]) / 2 - p[i]) * RIPPLE_DAMPING;
            }
        }
        waveCur = p;
        wavePrev = c;
    }

07行ごとに幅を狭めて一点へ吸い込まれる閉じ方

オプションを選んでカートに入れる窓は、閉じるときに窓の絵を横30行に切り、行ごとに目標点(上にあるカートのバッジ)までの距離に応じて出発を遅らせます。幅は先に狭め、位置と高さは二乗の曲線で遅れて動かすので、カートにいちばん近い最上行が405msで点に集まるころ最下行は495msでやっと動き出し、912msで全部が吸い込まれます。「カートに入れる」で閉じると窓がカートに届いた時点でバッジの数字が1増え(初めは2、99の次は1)、「閉じる」・Esc・幕で閉じると開くボタンの方へ吸い込まれます。

累積面積75.9%、20/23フレーム、強度平均41.4と測れました。

行ごとに幅を狭めて一点へ吸い込まれる閉じ方
新しいタブで開く

07 · 行ごとに幅を狭めて一点へ吸い込まれる閉じ方 — 計測 累積面積 75.871% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FUNNEL_ROWSFUNNEL_TARGET_XFUNNEL_DURATION_MS
    // 一行の進行率 — 目標点から遠い行ほど遅れて出発する(距離に比例した遅れ)。遅れた分、残りの時間でより速く追いつく
    function rowProgress(p, dn) { return clamp((p - FUNNEL_LAG * dn) / (1 - FUNNEL_LAG), 0, 1); }
    function itemPage() {
        drawOpener(1);
        const s = 1 + 0.4 * cartBump.x;
        ctx.save();
        ctx.translate(CART.x + CART.w / 2, CART.y + CART.h / 2);
        ctx.scale(s, s);
        fillRound(-CART.w / 2, -CART.h / 2, CART.w, CART.h, CART.h / 2, CART_FILL, 1);
        ctx.fillStyle = rgba(INK, 1);
        ctx.beginPath();
        ctx.arc(-CART.w / 2 + 12, 0, 4, 0, TAU);
        ctx.fill();
        ctx.font = fontOf(700, 12);
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(String(cartCount), 6, 0.5);
        ctx.restore();
    }
        // 遠い行から描き、目標点に近い行が上に来るようにする
        const order = [];
        for (let i = 0; i < FUNNEL_ROWS; i += 1) {
            order.push(i);
        }
        order.sort(function (a, b) { return dist[b] - dist[a]; });
        for (let n = 0; n < order.length; n += 1) {
            const i = order[n];
            const dn = (dist[i] - dmin) / Math.max(dmax - dmin, 1);
            const q = rowProgress(p, dn);
            // 幅は早く狭め(1.6倍速く)、位置は遅く動かす(二乗) — 先に細くなってから吸い込まれる
            const wq = smooth(q * 1.6);
            const mq = q * q;
            const w = lerp(PANEL.w, 3, wq);
            const cx = lerp(cx0, pointX, mq);
            const cy = lerp(PANEL.y + (i + 0.5) * rh, pointY, mq);
            const h = lerp(rh, rh * 0.2, mq);
            ctx.globalAlpha = 1 - smooth((q - 0.85) / 0.15);
            blit(spriteFull, 0, i * rh, PANEL.w, rh, cx - w / 2, cy - h / 2, w, h + 0.4);
        }
        ctx.globalAlpha = 1;
    }

08ガラスのように破片になって落ちる閉じ方

窓の上に7×5の点を敷いてシード4738でマスの±35%以内に揺らし、マスごとに対角線で切って三角形の破片48枚を作ります。割れた中心である削除ボタンの中央から外へ158.6〜237px/sの初速と回転を与えて重力1,400px/s²で落とすので、ひびの120msと落下900msを経た1,024msには48枚とも舞台の下へ抜けます。「キャンセル」・Esc・幕で閉じると割れずにばねで縮むため、ファイル削除の確認のように取り消せない操作にだけ砕ける動きが残ります。

この場面は累積面積75.9%、17/23フレーム、強度平均45.8です。

ガラスのように破片になって落ちる閉じ方
新しいタブで開く

08 · ガラスのように破片になって落ちる閉じ方 — 計測 累積面積 75.914% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SHARD_COUNTSHARD_GRAVITYSHARD_SPIN_MAX
    // 破片に分ける — 窓の上に (列+1)x(行+1) の点を敷いてシード乱数で揺らしたあと、マスごとに対角線一本で三角形を二つ作る。
    // 最初の速度は割れた中心(bx, by)から重心の方向(外向き)、近いほど速い
    function buildShards(bx, by) {
        const rng = makeRng(4738);
        const cw = PANEL.w / SHARD_COLS;
        const ch = PANEL.h / SHARD_ROWS;
        const px = [];
        const py = [];
        for (let r = 0; r <= SHARD_ROWS; r += 1) {
            for (let c = 0; c <= SHARD_COLS; c += 1) {
                let x = c * cw;
                let y = r * ch;
                if (c > 0 && c < SHARD_COLS) {
                    x += (rng() - 0.5) * cw * 0.7;
                }
                if (r > 0 && r < SHARD_ROWS) {
                    y += (rng() - 0.5) * ch * 0.7;
                }
                px.push(x);
                py.push(y);
            }
        }
        const idx = function (r, c) { return r * (SHARD_COLS + 1) + c; };
        const tris = [];

09開いて確認しボタンへ戻って閉じる決済確認モーダル

注文確定の窓は減衰比0.62のばねで開き、「確定する」を押すと押し込みばねの速度にPRESS_KICKの−10が入って、大きさk = 1 + press.x * PRESS_DEPTHが0.924倍まで沈んでから戻ります。押した瞬間からボタンは384msかけて丸まで細くなり、チェックの線が破線の間隔を縮めて描かれたあと、そのまま約300msとどまります。押してから688msで窓が開くボタンの位置へ縮みはじめ、1,136msで閉じ終わる一周なので注文の最終確認に合い、画面には支払い方法も金額もありません。

75.9%を覆い、19/23フレームで強度平均47.1でした。

開いて確認しボタンへ戻って閉じる決済確認モーダル
新しいタブで開く

09 · 開いて確認しボタンへ戻って閉じる決済確認モーダル — 計測 累積面積 75.903% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CONFIRM_HOLD_MSCHECK_MORPH_MSLOOP_MS
    // 確定ボタン — 押し込みばねで大きさが、モーフで幅(丸になるまで)・色・チェックの線が決まる。窓基準の座標
    function drawConfirm() {
        const m = morph();
        const bx = CONFIRM_BOX.x + CONFIRM_BOX.w / 2;
        const by = CONFIRM_BOX.y + CONFIRM_BOX.h / 2;
        const k = 1 + press.x * PRESS_DEPTH;
        const w = lerp(CONFIRM_BOX.w, CONFIRM_BOX.h, m) * k;
        const h = CONFIRM_BOX.h * k;
        roundRectPath(ctx, bx - w / 2, by - h / 2, w, h, lerp(BUTTON_RADIUS, h / 2, m), lerp(BUTTON_RADIUS, h / 2, m));
        ctx.fillStyle = rgba(mix(ACCENT, SUCCESS, m), 1);
        ctx.fill();
        const la = 1 - smooth(m / 0.4);
        if (la > 0.001) {
            ctx.save();
            ctx.globalAlpha *= la;
            ctx.translate(bx, by);
            ctx.scale(k, k);
            fitFont(ctx, CONFIRM_LABEL, 600, 12.5, CONFIRM_BOX.w - 14);
            ctx.fillStyle = rgba(INK, 1);
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.fillText(CONFIRM_LABEL, 0, 0.5);
            ctx.restore();
        }
        // チェックの線 — 折れ線二区間に線の長さ分の破線間隔を与え、その間隔を縮めて先頭から描いていく
        const ck = smooth((m - 0.35) / 0.65);
        if (ck > 0.001) {
            const len = Math.hypot(5, 5) + Math.hypot(10, 11);
            ctx.save();
            ctx.translate(bx, by);
            ctx.strokeStyle = rgba(INK, 1);
            ctx.lineWidth = 3;
            ctx.lineCap = 'round';
            ctx.lineJoin = 'round';
            ctx.setLineDash([len, len]);
            ctx.lineDashOffset = len * (1 - ck);
            ctx.beginPath();
            ctx.moveTo(-7, 0);
            ctx.lineTo(-2, 5);
            ctx.lineTo(8, -6);
            ctx.stroke();
            ctx.restore();
        }
    }

透明度と大きさだけを変えるモーダルは、なぜどこから来たのかを伝えられないのか?

ポップアップ アニメーション css を書くとき、まず思いつくのは透明度とscaleだけを動かす形です。その基準点は窓の中央にあるので、開くボタンが右下にあっても窓は中央からふくらんで中央へしぼみ、どこを押してこの窓が出たのかを動きが語りません。この舞台も開くボタンは右下(中央が396, 265)、窓は中央(104, 56から272×172)にあります。

そこで02はボタンの矩形から窓を育て、03はボタンの中央から円を広げ、07は閉じるときにカートか開くボタンへ吸い込まれ、09は閉じる窓を開くボタンの位置へ戻します。出発点と到着点を画面の中の実際の物に結びつけておけば、目は説明がなくても窓の出入りを追えます。キーフレームのフェードとスケールで組む形はCSS モーダル アニメーション 9選にまとまっていて、この編はその上に出発点を覚える計算を足した側です。

開いた位置を覚える矩形の補間と、円の半径をいちばん遠い角まで測る計算

ボタンの矩形を窓の矩形へ伸ばしながら文字は戻す02

02のgrowRectはlerpRect(OPENER, PANEL, e)で、開くボタン(332, 248、128×34)と窓(104, 56、272×172)の位置・幅・高さを同じ進行率eで補間します。親の層をctx.scale(sx, sy)で伸ばすと中の文字も一緒に伸びるので、子の層でctx.scale(1 / sx, 1 / sy)を掛けて戻します。始まった瞬間はsx 0.471・sy 0.198で、逆比率は2.125と5.059です。縦に5倍以上戻さないと文字がつぶれて見えるということで、進行50%(e 0.5)になると矩形は(218, 152, 200×103)、逆比率は1.36と1.67まで下がります。DOMで組むなら、窓にtransform: scale(sx, sy)、中身に逆のscaleを当てる二段の形になると考えられます。

いちばん遠い角を測らないと隅が残る03

円の中心(396, 265)から舞台の四隅までは、左上476.5px・右上278px・左下397.5px・右下91pxです。irisMaxRadiusがこの中から最大値を選ぶので、近い角までの距離を半径にしてしまったときのように、円が広がりきっても左上の隅だけ塗り残されることがありません。半径はIRIS_Rに減速曲線easeOutCubicの値を掛けたもので、10%で129.1px、25%で275.5px、50%で416.9px、75%で469px、100%で476.5pxです。

時間の半分で半径はもう87.5%に届き、残りの半分は最後の59.6pxをゆっくり埋めるのに使われます。画像の上に円や帯で絵を見せていく計算は画像 切り替え エフェクト 9種にもっと集めてあります。

窓が画面より大きいとき・スクロール中に開くとき・閉じる途中で開き直すとき — モーダルの遷移が壊れるところ

閉じる途中で開き直すと戻り方が項目ごとに違う

02・03・04・05は進行率が今の値から逆向きに戻り、01と09はばねの目標だけが変わるので、速さを引き継いだまま向きを変えます。07は吸い込まれかけた点から逆に広がって出てきて、08は割れている途中なら大きさ0からばねをやり直し、06はしずくをもう一つ落として波の上に波を重ねます。実演では2,400msの周回が切り替わる瞬間に、まだ閉じきっていない窓をsnapTo(false)で打ち切り、前の周の閉じる動きが次の周の開く動きと混ざらないようにしています。

窓が画面より大きいとき

窓の大きさは場面座標で272×172に固定され、画面が狭くなると舞台全体がaspect-ratio: 480 / 300を保ったまま縮みます。そのためこのデモで窓が画面からはみ出すことはなく、本文が長いときは最大の高さ56pxに収まるまで文字を0.5pxずつ小さくするだけです。長い規約の全文のように窓の中でスクロールさせる内容は扱っていないので、実際のサービスでは窓の中に別のスクロール領域が必要になると考えられます。

スクロール中に開くとき

開くとフォーカスは窓の中のボタンへ、閉じると開くボタンへ移ります。三か所の.focus()呼び出しはどれも{ preventScroll: true }を渡しているので、フォーカスの移動がページをそのボタンの方へ引っぱることはありません。一方で、窓が出ている間に背後のページのスクロールを止めるコードはこのデモにありません。開いた窓の後ろでページが流れるのを防ぐには、文書側にoverflow: hiddenのような固定を別に加える必要があると考えられます。

動いている間は絵と押せる場所がずれる

透明なDOMの層は、開ききった位置に固定されています。02のようにボタンの大きさから育っている途中や、08のように破片が落ちている途中は、絵とクリックを受ける場所が一致しません。開く途中で押すと最終位置を基準に受け取り、フォーカスの輪も開ききった位置に合わせてあるため、.md--openが付くまでは輪を透明にして隠しています。

文言を変えても絵は古い文字のまま

キャンバスはDOMの文字を読んで窓の絵を一度写し、その絵を使い回します。実行中に見出しや本文を書き換えても、大きさが変わってfitCanvasが絵を写し直すまでは画面に古い文言が残ります。このデモはロケールの文言を読み込む前に差し込んでおくことで、この問題を避けています。

波紋と画素は描く回数が多い

06はフレームごとにdrawImageを2,924回呼び、明暗の塗りも最大で同じ回数だけ重ねます。05は最大1,334回、04は87本の帯ごとに絵と陰を、08は破片48枚をそれぞれ切り抜いて描きます。キャンバスの倍率はMath.min(window.devicePixelRatio || 1, 2)で上限を2にしてあるので、倍率3の端末でも画素数は場面の4倍を超えません。開発中の06は減衰をもっと弱くしていたときに波紋が実演の閉じる時刻までに静まらず、今の0.95・しきい値6に変えました。09の押し込みも最初の値では大きさの変化が目に見えなかったため、PRESS_DEPTHを0.6に上げています。

九つのフォルダのvanillaソースと同じ計算を移したReact版もダウンロードできるzipに入っていて、展開するときにhp73ywt5を入れれば、自分のサービスの文言にすぐ差し替えて試せます。

ばねの減衰・ディゾルブのマス目・吸い込みの時間を変えると開閉はどう変わるか

名前 値 感じ
ばねの減衰を下げる 01 SPRING_DAMPING 14 → 8 14では減衰比0.57で、窓は104.1%まで一度ふくらみ、480msから1%以内にとどまります。8に下げると減衰比0.33になり、114.7%までふくらんで目標の上へ3回はね上がり、1,088msでようやく1%以内に入ります。浮き上がりも34.5pxまで深くなっては浅くなるのを繰り返します。
ディゾルブのマスを大きくする 05 DISSOLVE_CELL_PX 6 → 16 6pxなら46×29 = 1,334マスが細かな砂のように現れ、16pxでは17×11 = 187マスに減って粗いモザイクの欠片がぽんぽんと埋まります。進行50%で現れたマスも66.5%から58.3%に下がります。
吸い込みの時間を短くする 07 FUNNEL_DURATION_MS 900 → 500 900なら最上行が405msで点に集まり、最下行は495msに動き出すので漏斗の形がはっきり見えます。500では同じ比率が225ms・275msへ詰まって512msで吸い込みきり、行ごとの速さの差が短い時間に集まるぶん、窓が曲がる様子はあまり見えません。

表の三つのほかに、03のIRIS_ORIGIN_X・IRIS_ORIGIN_Yを動かすとirisMaxRadiusが新しい中心から四隅を測り直すので、半径を手で直す必要はありません。08のSHARD_COUNTの48とSHARD_ROWSの4はそのまま書いた値で、SHARD_COLSはそこからSHARD_COUNT / (2 * SHARD_ROWS)で計算されて6になります。破片の数を変えるときはSHARD_COUNTとSHARD_ROWSをいっしょに合わせ、列の数が整数になるようSHARD_COUNTをSHARD_ROWSの2倍の倍数にします。

窓が開いたことを伝える — ダイアログの役割と動きを減らすアクセシビリティ

九つの窓はどれも、透明なDOMの層にある<div class="md__dialog">がrole="dialog"とaria-modal="true"を持ち、aria-labelledbyで見出しの<h2>を、aria-describedbyで本文の<p>を指しています。開くボタンにはaria-haspopup="dialog"が付き、閉じている間のダイアログはhiddenなので、空の窓が読み上げられることはありません。

キーボードで開いて閉じ、フォーカスが戻るまで

Escは窓が出ているときだけ閉じる操作として受け取り、窓の外の幕を押しても閉じます。開いたときの最初のフォーカスは確認ボタンですが、08だけは取り消せない削除なので「キャンセル」が先にフォーカスを受けます。窓が出ている間は開くボタンにinertが掛かり、Tabが窓の中の二つのボタンの間だけを回るフォーカスの閉じ込めも入れてあります。閉じたときにフォーカスを開くボタンへ戻す部分は、九つのソースで同じ形です。

    function requestClose(user, via) {
        if (!shown()) {
            return;
        }
        const hadFocus = dialog.contains(document.activeElement);
        showDialog(false);
        setPhase('closing');
        itemClose(via);
        if (still) {
            snapTo(false);
        }
        // 閉じるとフォーカスは窓を開いたボタンへ戻る
        if (user || hadFocus) {
            openBtn.focus({ preventScroll: true });
        }
    }

人が押した閉じる操作か、フォーカスが窓の中にあったときだけフォーカスを動かすので、自動の実演が窓を閉じても見ている場所からフォーカスを奪いません。フォーカスの輪はfocus-visibleの規則で描き、窓が動いている間は透明にしておきます。状態表示はaria-live="off"で実演中は黙っていて、人が最初に操作した瞬間にpoliteへ切り替わり、その後の開閉を知らせます。

動きを減らす設定ではその場で開いて閉じる

prefers-reduced-motionでreduceを選んだ画面では、requestAnimationFrameのループを回しません。最初の画面は開ききった窓一枚で、開くのも閉じるのもsnapToがばねも破片もなしに最後の状態へすぐ描き替えます。09は「確定する」を押すと、押し込みとチェックを飛ばしてそのまま閉じます。九つのソースすべてでmatchMediaのchangeリスナーが設定を見張っていて、ページを開いたまま設定をオンにすると進んでいた向きの最後の姿で止まり、オフに戻すとstartLoop()でループがまた回り始めます。ダイアログの役割とaria-modalの意味はMDN の aria-modal の解説にまとまっています。

モーダル ウィンドウ アニメーションを実際のサービスに載せるときのよくある質問

モーダル ポップアップ 違いは、このデモのどこに表れていますか?

この編では、開いている間に背後のページを操作させない窓をモーダルとして組んでいます。ダイアログにaria-modal="true"を付け、開くボタンにinertを掛け、Tabを窓の中に閉じ込めたうえで、背後の幕を押すと閉じる作りです。背後を触れるまま小さく出る案内であればこうした閉じ込めは要らず、開閉の計算だけをそのまま使えると考えられます。

実際のモーダルもこのデモのようにキャンバスで描くべきですか?

その必要はなく、多くの場合はDOMの文字に同じ計算を載せるほうが向いています。このデモは窓を切り分けて動かす効果を見せるために文字までキャンバスで描いて透明なDOMを重ねましたが、そのせいで動いている間は絵とクリック位置がずれ、文言を変えると絵を写し直す必要があります。01・02・09のばねと矩形の補間はtransformへ、03の円はclip-path の circle()の半径へ計算値を入れればDOMのまま移せるので、キャンバスに残るのは05・06・07・08のように窓の絵をマス・行・破片に分ける効果です。ページどうしをつなぐ切り替えならview transitions api 9パターンが、写真を拡大して見せる窓ならcss ライトボックス 拡大アニメーション 9種まとめが近いです。

09を実際の注文確認の窓にそのまま使えますか?

デモのチェックは、押したあと決まった時間表どおりに描かれるだけで、注文の要求はどこにも送りません。実際のサービスなら、押し込みのばねまではすぐ見せて、チェックのモーフと縮小は注文の応答を受け取ってから始める順番に変えるほうが合っています。そうすれば失敗した注文にチェックが描かれることはなく、チェックを描いている途中でもEscや「閉じる」は効いてすぐ縮小に移ります。

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

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