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

css fireworks 9パターン — 打ち上がり・尾を引き・水面に映る花火の場面

css fireworks を弾けさせるには、花火ひとつではなく数百の粒の軌道を先に決める必要がある — 01番の球面分布と07番の二次分裂がその軌道を計算で編む。この記事の九つの場面は、どれも CSS ではなくキャンバスに計算で描いたものです。

九つの花火を画面でそのまま打ち上げてみる

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

css fireworks 9パターン — 重力・抗力・尾の履歴で弾ける花火

先に断っておくと、九つの場面は CSS のキーフレームではなく、<canvas> の2Dに粒を一つずつ計算して描いています。花火ひとつが数十から数百の粒でできていて、粒ごとに速さと尾の位置がフレームごとに違うため、キーフレームで一つずつ書くには数が多すぎました。CSS が受け持つのは舞台の大きさと縦横比、タッチ操作の設定といった外枠だけです。

九つは同じ骨組みを使い、場面の関数だけを差し替えます。FIXED_DT の16ms間隔で step を回して粒を動かし、描くたびに draw を呼び、乱数は場面ごとに種を決めた lcg ひとつだけです。Math.random を呼ぶ行は九つのファイルのどこにもないので、同じ種なら同じ順番で花火が上がります。場面を作るときに PREWARM_STEPS ティックを先に回してあるため、最初の画面から花火が空に浮かんでおり、ライブラリ・CDN・WebGL も使っていません。

計算の中心は一行です。毎ティック v ← v·DRAG + 重力 を繰り返すと速さが指数で減り、粒はどこかで止まります。01の BURST_SPEED 3.4と DRAG 0.975から重力を除いて考えると、到達距離は3.4 / 0.025 = 136pxです。牡丹・柳・尾・クラックルは、この一行に初速の分布や尾の長さ、分裂の規則を載せた違いにすぎません。

pure css fireworks を探してここに来た方は、使い道から出発点を選べば足ります。お祝いイベントのランディングなら01、クリックした高さで開いてほしい報酬演出なら02、夜のヒーロー背景なら04、ロウソクのような情緒的なセクションなら05、湖や海辺のテーマなら06、イベントの締めの画面なら08、製品発表のヒーローなら09から開いてみてください。

css fireworks animation を技法別のコードで見る

ここに載せるコードは、各場面の index.html にある <script> の連続した行を切り出したものです。長さはどれも25行以内で、関数や定数の名前は書き換えていません。zip の中で同じ名前を検索すれば、同じ箇所に行き着きます。

01粒が球面に均等に広がり空気抵抗で速さが削られる基本の炸裂

粒の向きはフィボナッチ格子で球面に均等に割り振り、毎ティック速度に DRAG を掛けてから縦方向にだけ GRAVITY を足します。奥行き z は画面に打たれず粒の大きさと明るさだけを変えるので、平面に描いても球に見えます。この場面は面積23.0%・強度86.8で、23/23フレームで動きが検出されました。

粒が球面に均等に広がり空気抵抗で速さが削られる基本の炸裂
新しいタブで開く

01 · 粒が球面に均等に広がり空気抵抗で速さが削られる基本の炸裂 — 計測 累積面積 22.972% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BURST_NGRAVITYDRAGFUSE_MS
    function sphereDirs(n) {
        const out = [];
        for (let i = 0; i < n; i += 1) {
            const y = 1 - 2 * (i + 0.5) / n;
            const r = Math.sqrt(1 - y * y);
            const a = i * GOLDEN_ANGLE;
            out.push([r * Math.cos(a), y, r * Math.sin(a)]);
        }
        return out;
    }
    // 1フレーム — 速度に抗力係数を掛け、下向きに重力を足す
    function moveGrain(g) {
        g.px = g.x;
        g.py = g.y;
        g.vx *= DRAG;
        g.vy = g.vy * DRAG + GRAVITY;
        g.vz *= DRAG;
        g.x += g.vx;
        g.y += g.vy;
        g.z += g.vz;
    }

02クリックした高さでちょうど頂点に達して炸裂する放物線の打ち上げ

押した高さ h から v0 = √(2·LAUNCH_G·h) で初速を逆算し、上昇速度が初速の4%を下回ったティックで炸裂させるので、押した高さがそのまま頂点になります。標的の y を60・100・140・190に置いて burstHeight() をノードから呼ぶと、実際に開いた高さは60.3・100.1・140.1・190.1で、誤差は0.3px以内でした。レンダリング側の測定は面積58.2%・強度76.6で、23/23フレームが動いています。

クリックした高さでちょうど頂点に達して炸裂する放物線の打ち上げ
新しいタブで開く

02 · クリックした高さでちょうど頂点に達して炸裂する放物線の打ち上げ — 計測 累積面積 58.175% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LAUNCH_GAPEX_FRACTRAIL_LENCLICK_PAD
    function launchSpeed(h) {
        return Math.sqrt(2 * LAUNCH_G * h);
    }
    function aimPoint(x, y) {
        return [clamp(x, CLICK_PAD, W - CLICK_PAD), clamp(y, CLICK_PAD, GROUND_Y - 60)];
    }
    function makeRocket(tx0, ty0, hue) {
        const p = aimPoint(tx0, ty0);
        const h = GROUND_Y - p[1];
        const v0 = launchSpeed(h);
        const x0 = lerp(W / 2, p[0], 0.35);
        const flight = v0 / LAUNCH_G;
        return { x: x0, y: GROUND_Y, vx: (p[0] - x0) / flight, vy: -v0, v0: v0, tx: p[0], ty: p[1], trail: [], hue: hue };
    }
    // 1フレーム上げて頂点に届いたかを返す(上昇速度が頂点のしきい値を下回れば真)
    function stepRocket(r) {
        r.x += r.vx;
        r.y += r.vy + LAUNCH_G * 0.5;
        r.vy += LAUNCH_G;
        r.trail.push(r.x, r.y);
        if (r.trail.length > TRAIL_LEN * 2) {
            r.trail.splice(0, 2);
        }
        return r.vy >= -APEX_FRAC * r.v0;
    }

03牡丹・柳・輪が速度分布ひとつで分かれる三種類の玉

同じコードで初速の分布と抗力だけを変えて、牡丹・柳・輪を作ります。牡丹は初速を約1.8から5.1までの幅に散らして詰まった球に、柳は最大付近の速さだけを使って薄い殻に尾を13点つけ、輪は速さ3.5ひとつで円を描いてその面を RING_TILT だけ傾けます。面積28.3%・強度93.3は九つの場面で強度が最も高く、23/23フレームで動きました。

牡丹・柳・輪が速度分布ひとつで分かれる三種類の玉
新しいタブで開く

03 · 牡丹・柳・輪が速度分布ひとつで分かれる三種類の玉 — 計測 累積面積 28.288% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WILLOW_DRAGRING_TILTSHELL_TYPESTAIL_FADE
        for (let i = 0; i < GRAIN_N; i += 1) {
            let vx = 0;
            let vy = 0;
            let z = 0;
            if (kind === 'ring') {
                // 同じ速さで円を描き、円が載る面を RING_TILT だけ傾ける
                const a = (i / GRAIN_N) * PI2;
                const sp = 3.5;
                vx = Math.cos(a) * sp;
                vy = Math.sin(a) * Math.cos(RING_TILT) * sp;
                z = Math.sin(a) * Math.sin(RING_TILT);
            }
            else {
                const a = rng() * PI2;
                // 牡丹は0から最大まで、柳は最大付近にだけ速さを置く
                const sp = kind === 'willow' ? 3.1 * (0.85 + 0.15 * rng()) : 5.1 * Math.sqrt(0.12 + 0.88 * rng());
                vx = Math.cos(a) * sp;
                vy = Math.sin(a) * sp;
            }
            grains.push({ x: x, y: y, vx: vx, vy: vy, z: z, hist: [] });
        }

04過去の位置の履歴をつなぎ、重なるほど明るくなる尾と光の輪

粒ごとに直近16点の位置を保存して線分でつなぎ、後ろへ行くほど透明度が FADE_ALPHA の累乗で下がって尾が薄れます。合成方式を lighter にしてあるので、尾と頭の光の輪が重なった所は色が加算され、白に近く滲みます。面積41.6%・強度72.1で、23/23フレームで動きがありました。

過去の位置の履歴をつなぎ、重なるほど明るくなる尾と光の輪
新しいタブで開く

04 · 過去の位置の履歴をつなぎ、重なるほど明るくなる尾と光の輪 — 計測 累積面積 41.561% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TRAIL_LENGLOW_BLURFADE_ALPHAHEAD_R
    function trailAlpha(back) {
        return Math.pow(FADE_ALPHA, back);
    }
                    const life = Math.pow(1 - s.age / s.life, 0.7);
                    const n = s.hist.length / 2;
                    for (let k = 1; k < n; k += 1) {
                        const a = trailAlpha(n - k) * life;
                        ctx.strokeStyle = rgba(PALETTE[s.hue], a * 0.85);
                        ctx.lineWidth = lerp(0.6, HEAD_R * 1.3, k / n);
                        ctx.beginPath();
                        ctx.moveTo(s.hist[(k - 1) * 2], s.hist[(k - 1) * 2 + 1]);
                        ctx.lineTo(s.hist[k * 2], s.hist[k * 2 + 1]);
                        ctx.stroke();
                    }

05白から橙、赤い灰へと冷めながら瞬いて消える火の粉

火の粉の寿命の割合を五段階の色の並びに通して、白から薄黄・杏・薔薇色・灰へと冷ましつつ、明るさは1次元の値ノイズで揺らします。揺れ幅に掛ける 0.3 + 0.7·frac² は寿命の終わりに近いほど大きくなるので、消える直前にいちばんよくちらつきます。数値は面積25.0%・強度64.2で、23/23フレームで動いています。

白から橙、赤い灰へと冷めながら瞬いて消える火の粉
新しいタブで開く

05 · 白から橙、赤い灰へと冷めながら瞬いて消える火の粉 — 計測 累積面積 24.988% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FLICKER_HZCOOL_STOPSLIFE_MSNOISE_AMP
    function coolColor(t) {
        const c = clamp(t, 0, 1);
        for (let i = 1; i < COOL_STOPS.length; i += 1) {
            if (c <= COOL_STOPS[i][0]) {
                const a = COOL_STOPS[i - 1];
                const b = COOL_STOPS[i];
                return mixRGB(a[1], b[1], (c - a[0]) / (b[0] - a[0]));
            }
        }
        return COOL_STOPS[COOL_STOPS.length - 1][1];
    }
    // 火の粉ひとつの明るさ — 1次元ノイズで揺らし、冷めるほど揺れ幅が大きくなる
    function flickerOf(id, seconds, frac, amp) {
        const n = noise1(id * 3.7 + seconds * FLICKER_HZ, 7);
        return 1 - amp * n * (0.3 + 0.7 * frac * frac);
    }

06水面に逆さに映りさざ波で歪む花火の反射

空だけを別に描いて原本にしておき、水の j 行目には水平線から j 行上の空を反転して貼ります。行ごとの rowShift が正弦波で横ずれを与え、深いほどアルファが薄くなるので、さざ波に歪んだ姿になります。面積22.8%・強度85.8・23/23フレームで、面積は九つの中でいちばん小さい場面です。

水面に逆さに映りさざ波で歪む花火の反射
新しいタブで開く

06 · 水面に逆さに映りさざ波で歪む花火の反射 — 計測 累積面積 22.779% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MIRROR_ALPHAWAVE_AMPWAVE_KHORIZON_Y
    function rowShift(j, tick) {
        const depth = 0.5 + j / (H - HORIZON_Y);
        return WAVE_AMP * depth * Math.sin(WAVE_K * j + tick * WAVE_SPEED) + WAVE_AMP * 0.35 * (noise1(j * 0.2 + tick * 0.05, 3) - 0.5);
    }
                const rows = H - HORIZON_Y;
                const wobble = reduce ? 0 : t;
                for (let j = 0; j < rows; j += 1) {
                    // 水平線から j 行下の水は、j 行上の空を反転して見せる
                    const sy = (HORIZON_Y - 1 - j) * dpr;
                    ctx.globalAlpha = MIRROR_ALPHA * (1 - j / (rows * 1.5));
                    ctx.drawImage(cv, 0, sy, W * dpr, dpr, rowShift(j, wobble), HORIZON_Y + j, W, 1);
                }

07粒が寿命の終わりでさらに細かく弾ける二段のクラックル

寿命が尽きた親の粒が SPLIT_P の確率で子を五つ作り、子はもっと短く生きて消え、深さの制限のためそれ以上は弾けません。爆発が二段で止まるこの仕掛けがなければ、粒の数が雪だるま式に膨らんでしまいます。面積65.2%は九つの中で画面をいちばん広く使い、強度75.2・23/23フレームです。

粒が寿命の終わりでさらに細かく弾ける二段のクラックル
新しいタブで開く

07 · 粒が寿命の終わりでさらに細かく弾ける二段のクラックル — 計測 累積面積 65.167% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPLIT_DEPTHCHILD_NCHILD_SPEEDSPLIT_P
    function splitChildren(p, rng) {
        const kids = [];
        if (p.depth >= SPLIT_DEPTH || rng() >= SPLIT_P) {
            return kids;
        }
        const base = rng() * PI2;
        for (let i = 0; i < CHILD_N; i += 1) {
            const a = base + (i / CHILD_N) * PI2;
            const sp = CHILD_SPEED * (0.7 + rng() * 0.6);
            kids.push({ x: p.x, y: p.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 20 + rng() * 10, depth: p.depth + 1, hue: CREAM });
        }
        return kids;
    }

08炸裂のたびに空が染まり、建物のシルエットが距離ごとに明るくなる連続打ち上げ

炸裂のあった場所を光源として記録し、建物の帯ごとに全光源の寄与を足して色を決めます。寄与は距離が離れるほど 1 / (1 + (d/90)²) の形で弱まります。一度に四発を撃って休む仕組みで、発と発の間隔が短いほど空と建物が明るく重なります。面積45.1%・強度44.2は強度が九つの中でいちばん低く、23/23フレームで動きました。

炸裂のたびに空が染まり、建物のシルエットが距離ごとに明るくなる連続打ち上げ
新しいタブで開く

08 · 炸裂のたびに空が染まり、建物のシルエットが距離ごとに明るくなる連続打ち上げ — 計測 累積面積 45.078% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

VOLLEY_NSKY_GLOWLIGHT_FALLOFFGAP_MS
    function lightAt(f, px, py) {
        const d = Math.hypot(f.x - px, f.y - py);
        const k = 1 - f.age / FLASH_LIFE;
        return (k * k) / (1 + (d / LIGHT_FALLOFF) * (d / LIGHT_FALLOFF));
    }
                    for (let band = 0; band < BAND_N; band += 1) {
                        const cy = H - bd.h + (band + 0.5) * bandH;
                        let r = BUILDING_BASE[0];
                        let gg = BUILDING_BASE[1];
                        let bb = BUILDING_BASE[2];
                        for (let i = 0; i < flashes.length; i += 1) {
                            const l = lightAt(flashes[i], bd.x + bd.w / 2, cy) * 1.9 * gain;
                            r += flashes[i].hue[0] * l * 0.55;
                            gg += flashes[i].hue[1] * l * 0.55;
                            bb += flashes[i].hue[2] * l * 0.55;
                        }
                        ctx.fillStyle = 'rgb(' + Math.round(clamp(r, 0, 255)) + ',' + Math.round(clamp(gg, 0, 255)) + ',' + Math.round(clamp(bb, 0, 255)) + ')';
                        ctx.fillRect(bd.x, H - bd.h + band * bandH, bd.w, bandH + 0.5);
                    }

09ポインターの風で煙の雲が流され、花火が打ち上がるヒーロー場面

炸裂の残した煙の粒をノイズの向きでゆっくり流し、ポインターが動いた速さぶんだけ近くの粒に風を足します。人が触れていない間は偽のポインターが横に掃いて通るので、煙が裂ける様子を先に見せてくれます。面積28.3%・強度46.5で、23/23フレームで動きました。

ポインターの風で煙の雲が流され、花火が打ち上がるヒーロー場面
新しいタブで開く

09 · ポインターの風で煙の雲が流され、花火が打ち上がるヒーロー場面 — 計測 累積面積 28.317% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WIND_RSMOKE_NNOISE_SCALEDRAFT
    function windPush(dist, speed) {
        if (dist >= WIND_R) {
            return 0;
        }
        const k = 1 - dist / WIND_R;
        return speed * WIND_GAIN * k * k;
    }

値が花火の印象を変えるところ — 粒の数・抗力・波の大きさ

値ひとつで印象が大きく変わるのは三か所で、そこに値を動かさずに確かめられる頂点の誤差を加えて四つ並べます。以下の数字はブラウザのレンダリングではなく、ソースの関数をノードの擬似 DOM から直接呼んで数えたものなので、すぐ下の測定表とは別物として読んでください。

九つの場面を書き出して集めた動きの数値は、次の表のとおりです。

場面 累積面積 強度の平均 動いたフレーム
01 球面の炸裂 23.0% 86.8 23/23
02 頂点の打ち上げ 58.2% 76.6 23/23
03 牡丹・柳・輪 28.3% 93.3 23/23
04 加算の尾 41.6% 72.1 23/23
05 火の粉 25.0% 64.2 23/23
06 水面の反射 22.8% 85.8 23/23
07 クラックル 65.2% 75.2 23/23
08 街のシルエット 45.1% 44.2 23/23
09 煙と風 28.3% 46.5 23/23

BURST_N を90から240に上げると、占有マスが90から227に増える

01で80ティック経った一フレームを6pxのマスに区切って数えると、別々のマスを占める粒は90個から227個になり、最も近い隣との平均距離は14.3pxから7.4pxに縮みます。30ティック後は88個から210個、8.8pxから4.6pxです。点に見えた球が、薄い膜のように満ちていきます。

WILLOW_DRAG を0.96から0.99に上げると、平均の到達距離が2.26倍になる

03の柳が124ティックの間に出発点から飛んだ平均の距離は、132.5pxから299.5pxに伸びます。画面の高さが300pxなので、枝は底の近くまで垂れます。抗力係数が1に近づくほど速さがほとんど減らないためで、0.005上げるだけでも長さの差がはっきり出るので、この定数は少しずつ動かしてください。

WAVE_AMP を3から10に上げると、行のずれの最大値が4.8pxから16.1pxになる

06で120ティックの間に水の110行が横にずれた最大値は、4.8pxから16.1pxに伸びました。粒ひとつの幅は約5pxなので、歪みは幅の1倍強から3倍超へ広がり、反射は花火の形として見分けにくくなります。ティック10では、一番上の行が1.2pxから4.0px、一番下の行が-4.3pxから-14.5pxに動きました。

標的の高さ60から190まで、頂点の誤差は0.3px以内

02の初速は、標的の y が60・100・140・190のときそれぞれ7.23・6.54・5.75・4.60で、実際に開いた高さは60.3・100.1・140.1・190.1です。APEX_FRAC を0.04より大きくすると頂点に届く前に開いてしまい、誤差は APEX_FRAC²·h ぶん大きくなります。クリックした場所で開く手応えを守っているのは、この定数です。

どこで壊れるか — 粒が多すぎるとき・タブが止まるとき・動きを減らす設定

九つの場面を実際に触っていると、まず目につくのは粒の数・抗力・入力の扱いです。以下はソースの定数とノードからの呼び出しで確かめたもので、遅くなる度合いをブラウザで測ってはいません。

粒が増えると、マスを分け合って描画呼び出しだけが増える

01で BURST_N が240なら、80ティック後も13個は他の粒と同じマスにいます。同じ場面では MAX_SHELLS の5発が同時に生きられるので、粒ごとに線一本と円一つを描くとして、1フレームあたり240 × 5 × 2 = 2400回の描画呼び出しが要ります。DRAG を1以上に上げると速さが減らず、画面を出た粒も GRAIN_LIFE まで更新され続けます。

柳の抗力が1に近いと、画面の外でも計算が続く

03で WILLOW_DRAG が0.99になると平均の到達距離は299pxで、いちばん速い粒は480×300の画面を出てしまいます。ソースは寿命の124ティックが尽きるまで粒を消さないので、画面外の粒の尾の計算もそのぶん続きます。

火の粉は上限で刈られ、ノイズ振幅が1だとちらつきではなくなる

05はティックごとに火の粉を4つ作り、MAX_EMBERS の420を超えると古いものから刈り取ります。標準では平均約360個で上限に届きませんが、LIFE_MS を延ばすと先にこの上限に当たります。NOISE_AMP が1だと明るさが0まで揺れて、ちらつくのではなく消えては現れるように見えます。

反射は毎フレーム、水の行数ぶん drawImage を呼ぶ

06は H − HORIZON_Y の110行に、行単位で drawImage を一回ずつ呼びます。getImageData は九つの場面のどこにもありません。空の原本は devicePixelRatio が変わると作り直し、水平線より下に落ちた粒は空の原本に描かれないので、反射が生まれません。

親1000個のうち571個が分かれると、弾けるたびにクラックルの量が違う

07で親1000個を仮定して splitTally(1000, 5) を呼ぶと、571個(57.1%)が分かれて子が2855個(571 × 5)になり、孫は0個でした。実際の一段は親が84個なので、分かれる数は46個前後で揺れ、弾けるたびにクラックルの量が少しずつ違います。SPLIT_DEPTH を2に上げると子も分かれ、粒の数は分かれるたびに SPLIT_P × CHILD_N = 2.75倍ずつ増えます。

四発が撃たれる間も、最初の一発の光はまだ生きている

08は一度に VOLLEY_N の4発を、GAP_MS の260ms(16ティック)間隔で撃ちます。四発は最初から四発目まで48ティックにわたって出ますが、FLASH_LIFE は60ティックなので、四発目が出る時点でも最初の光はあと12ティック残り、四つの光が重なります。ひとつの光の明るさは (1 − age/60)² / (1 + (d/90)²) で、90px離れると半分になり、光源は8個までで、超えると古いものから消します。

ポインターの速さはリフレッシュレートに左右され、キャンバスの外に出ると途切れる

09の inp.vx はポインターのイベントごとに更新され、リフレッシュレートの高いモニターでは同じ手の動きでも速さが大きくなるため、ソースは±12で切り詰めています。風の強さは速さ v に0.1を掛け、距離 d に応じて (1 − d/70)² で弱まり、半径70pxの外では0です。触っていない間は、偽のポインターが 240 + 150·sin(0.028·t) で、中央から左右150pxの幅を往復します。九つのソースはどれも setPointerCapture が0回なので、ポインターがキャンバスの外へ出ると pointermove が途切れ、風も止まります。引いている間に画面がスクロールしないようにする touch-action: none は09だけに付き、残りの八つの場面は manipulation です。

タブが止まって戻ると、追いつく時間に上限がある

九つのソースは、一回のフレームで流す時間を Math.min(ts - lastTs, 160) で抑えています。タブを長く隠して戻っても一度に進むのは最大10ティックで、花火が一気に暴走することはありません。代わりに、隠していた間の時間は追いつかずに捨てられます。

02は端を押すと、押した場所と開く場所がずれる

02の aimPoint は、横を CLICK_PAD の28だけ内側に、縦を GROUND_Y − 60 より上に引き寄せます。そのため、地面の近くを押すと y 218 で開き、角を押すと28px内側で開きます。この範囲の外へ飛ばしたいなら、aimPoint を書き換える必要があります。

定数を一つずつ動かして、花火がどこで崩れるかを自分で確かめたいなら、zip を開くのが近道で、パスワードは r9k5d5jr です。中身は、九つの場面それぞれの index.html・style.scss と、React 版です。

変形三つとアクセシビリティ

名前 値 感じ
粒の数を変える 01 BURST_N 90 → 240 80ティック後に別々の6pxマスを占める粒が90個から227個になり、隣との平均距離は14.3pxから7.4pxに縮んで、点の球が薄い膜のように満ちます。
柳の抗力を変える 03 WILLOW_DRAG 0.96 → 0.99 124ティックで飛ぶ平均の距離が133pxから299pxへ2.3倍に伸び、画面の高さほどに枝が垂れます。
波の大きさを変える 06 WAVE_AMP 3 → 10 行のずれの最大値が4.8pxから16.1pxに伸び、粒の幅の3倍を超えて広がるため、反射が散ります。

この三つの数字も、上の値の節と同じく、ソースの関数をノードで直接呼んで測った値です。

キーボードだけでも九つの場面を撃てます。キャンバスには tabindex="0" があって Tab で焦点が届き、aria-label には項目の名前が入っています。Enter と Space は押したときの動作の代わりをし、矢印キーは仮想のポインターを KEY_STEP の14ずつ動かしますが、スクロールや Tab の移動を妨げないよう、preventDefault はこの六つのキーにしか掛けていません。スクリーンリーダー向けの状態文は aria-live="polite" の <p> ひとつで、場面が決めた文字列が変わったときだけ更新されます。

prefers-reduced-motion を有効にしている人には、九つの場面とも requestAnimationFrame のループを回さず、止まった最初の場面だけを見せます。一時停止ボタンの位置には「次の場面」が入り、押すごとに0.9秒ぶんだけ花火が進みます。閃光は別に弱めてあります。01の中心の閃光はアルファ0.8から0.35へ、04の光の輪は0.85から0.6へ下げ、05は NOISE_AMP に0.4を掛け、06は波の時計を止め、08は gain を0.5にします。止まった場面にも花火は残るので、空の画面を見せることはありません。

css fireworks background だけで足りるか?

背景一面に敷く効果を思い浮かべがちですが、この記事の九つの場面は舞台の中でしか動かしておらず、ページ全体に被せたことはありません。被せるなら、キャンバスが下の要素のクリックを奪わないかが最初の確認事項で、入力を受ける02と09は特にそうです。

外枠が担うのは舞台の大きさだけです。.sc は width: min(92vw, calc(160vh - 12.8vw)) に aspect-ratio: 480 / 300 で、座標はいつも480×300の論理座標なので、ウィンドウの大きさが変わっても場面の計算は同じです。粒の計算がいらない一回きりの効果なら、紙片が舞う 紙吹雪 アニメーション css キラキラ演出9選 のほうが軽く、火の動きを計算で見たいなら キャンバス 炎 アニメーション9選 — シード固定パーティクルで描く火のシーン とあわせて、星が流れる背景は css starfield 9パターン — 星が奥行きに沿って流れる宇宙の背景 にあります。計算の根拠は requestAnimationFrame と globalCompositeOperation のドキュメントで確認しました。

css fireworks codepen のコードを使い回す前によくある質問

pure css fireworks で、キーフレームだけの版はありませんか?

この記事にはありません。九つの場面はすべてキャンバスで、CSS が受け持つのは舞台の大きさ・縦横比・touch-action とボタンの形だけです。粒の数ぶんのキーフレームを手で書き並べる版は作っていません。

粒を増やすと、どこから先に重くなりますか?

ソースを読んで見積もると、粒ごとに線一本と円一つを描く01は粒の数にそのまま比例し、06は粒の数と関係なく毎フレーム110回の drawImage が固定でかかります。増やしてフレーム時間を測ってはいません。

画面の大きさが変わると、花火の大きさも変わりますか?

変わります。場面の座標は480×300に固定で、舞台がビューポートに比例して伸び縮みするため、スマホでは粒がそのぶん小さく見えます。内部の解像度は devicePixelRatio を2まで反映して、fit() が合わせ直します。

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

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