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

CSS Fireworks: 9 Bursts, Trails and Reflections

Before a css fireworks burst can pop, hundreds of grain paths have to be decided first — scene 01's sphere spread and scene 07's secondary split weave

Set Off All Nine Fireworks Right on This Page

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

CSS Fireworks: 9 Bursts Driven by Gravity, Drag and Trail History

One thing up front: all nine are <canvas> 2D simulations, and none is a CSS keyframe animation. Each one draws grains one at a time onto a <canvas> 2D context, because a single burst is dozens to hundreds of grains and every grain has its own speed and trail position on every frame. Writing that out as keyframes by hand would take thousands of lines. CSS only handles the frame around the stage, meaning its size, its aspect ratio and the touch behavior.

All nine share one skeleton and swap only the scene function. A loop steps the grains forward at a fixed FIXED_DT of 16ms and calls draw after each step, and the only source of randomness is a seeded generator called lcg, so the same seed gives the same sequence of bursts. The nine files never call Math.random. Each scene also runs PREWARM_STEPS ticks as it is built, so the very first frame already has sparks hanging in the air, and there is no library, no CDN and no WebGL.

The core of the math is one line per tick: v ← v·DRAG + gravity. Speed shrinks exponentially, so a grain stops somewhere. Take scene 01's BURST_SPEED of 3.4 and DRAG of 0.975, leave gravity out, and the reach is 3.4 / 0.025 = 136px. Peony, willow, trail and crackle are that one line with a different speed spread, tail length or split rule laid on top, and a css fireworks effect that looks hand-drawn is usually this small loop underneath.

If you came looking for a css fireworks effect for a specific page, pick the starting scene by the job. A celebration landing begins at 01, a reward for a click at an exact height at 02, a night hero backdrop at 04, a candle-like quiet section at 05, a lake or beach theme at 06, an event closing screen at 08 and a product launch hero at 09.

Code for Each Technique Behind a CSS Fireworks Click Effect

Each snippet below is a run of consecutive lines taken from the <script> of one scene's index.html, and none goes past 25 lines. Function and constant names are exactly what the source uses, so you can search the zip for the same letters. If you want css fireworks on click, note that in all nine a pointer press or the Enter key calls the scene's tap(), and what tap() launches is the only thing that differs from scene to scene.

01A Basic Burst Whose Grains Spread Evenly Over a Sphere While Air Drag Eats Their Speed

Grain directions are spread over a sphere with a Fibonacci lattice, then every tick multiplies the speed by DRAG and adds GRAVITY to the vertical part only. The depth z is never drawn as a position, but it changes grain size and brightness, so a flat canvas still reads as a ball. The render measured 23.0% cumulative area, intensity 86.8 and motion in 23/23 frames.

A Basic Burst Whose Grains Spread Evenly Over a Sphere While Air Drag Eats Their Speed
Open in a new tab

01 · A Basic Burst Whose Grains Spread Evenly Over a Sphere While Air Drag Eats Their Speed — Measured area 22.972% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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;
    }
    // One frame: multiply the speed by the drag factor and add gravity downward
    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;
    }

02A Parabolic Rocket That Peaks Exactly at the Clicked Height and Bursts There

The pressed height h gives the launch speed backwards through v0 = √(2·LAUNCH_G·h), and the burst fires on the tick the climb speed has dropped below 4% of that launch speed. So the pressed height becomes the apex. Calling burstHeight() in node with target y values 60, 100, 140 and 190 gave bursts at 60.3, 100.1, 140.1 and 190.1, and the render read 58.2% area, intensity 76.6 and 23/23 frames.

A Parabolic Rocket That Peaks Exactly at the Clicked Height and Bursts There
Open in a new tab

02 · A Parabolic Rocket That Peaks Exactly at the Clicked Height and Bursts There — Measured area 58.175% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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 };
    }
    // Move up one frame and report whether the apex is reached (true once the climb speed drops below the apex threshold)
    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;
    }

03Three Shell Types — Peony, Willow and Ring — Split by a Single Speed Distribution

The same code makes peony, willow and ring by changing only the launch speed spread and the drag. Peony spreads speeds widely, from about 1.8 up to 5.1, and fills a ball, willow uses only speeds near the maximum so the grains form a thin shell with a 13-point tail, and ring uses one speed of 3.5 and tilts the plane of the circle by RING_TILT. The render measured 28.3% area and intensity 93.3, the highest intensity of the nine, with motion in 23/23 frames.

Three Shell Types — Peony, Willow and Ring — Split by a Single Speed Distribution
Open in a new tab

03 · Three Shell Types — Peony, Willow and Ring — Split by a Single Speed Distribution — Measured area 28.288% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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') {
                // Draw a circle at one speed and tilt the plane it lies on by 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;
                // Peony spreads speeds from 0 up to the maximum, willow keeps them near the maximum
                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: [] });
        }

04A Trail That Links Past Positions and Brightens Where Layers Overlap

Every grain keeps its last 16 positions and joins them with line segments, and the alpha falls off further back as a power of FADE_ALPHA, so the tail thins toward its end. The composite mode is lighter, which adds colors where the tail and the head glow overlap and pushes them toward white. The render measured 41.6% area and intensity 72.1, and every one of the 23 frame gaps showed motion (23/23).

Tails Drawn From Position History and a Glow That Brightens Where They Overlap
Open in a new tab

04 · Tails Drawn From Position History and a Glow That Brightens Where They Overlap — Measured area 41.561% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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();
                    }

05An Ember That Cools from White Through Orange to Ashen Red While It Flickers Out

An ember's life fraction goes through a five-stop color list, so it cools from white to pale yellow, apricot, rose and ash, and its brightness is shaken by one-dimensional value noise. The flicker amplitude is multiplied by 0.3 + 0.7·frac², which grows as life runs out, so the strongest flicker comes just before an ember dies. The render measured 25.0% area and intensity 64.2 with motion in 23/23 frames.

Embers That Cool From White Through Orange to Red Ash, Flickering Before They Die
Open in a new tab

05 · Embers That Cool From White Through Orange to Red Ash, Flickering Before They Die — Measured area 24.988% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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];
    }
    // Brightness of one ember: shaken by 1D noise, and the swing grows as it cools
    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);
    }

06A Reflection on the Water That Flips Upside Down and Warps with Ripples

The sky is drawn alone into an offscreen canvas and kept as the source, and row j of the water pastes the sky row j rows above the horizon, flipped. rowShift moves each row sideways on a sine wave, and the alpha fades with depth, so the mirror image ripples. This scene has the smallest area of the nine at 22.8%, with intensity 85.8 and motion in 23/23 frames.

A Reflection Flipped Onto the Water and Distorted by Ripples
Open in a new tab

06 · A Reflection Flipped Onto the Water and Distorted by Ripples — Measured area 22.779% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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) {
                    // The water j rows below the horizon shows the sky j rows above it, flipped
                    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);
                }

07A Two-Layer Crackle Where a Grain Bursts Again at the End of Its Life

A parent grain whose life ends splits into five children with probability SPLIT_P, and the children live shorter and cannot split again because of the depth limit. That limit is what keeps the grain count from snowballing after every burst. At 65.2% this scene covers the most area of the nine, with intensity 75.2 and motion in 23/23 frames.

A Two-Layer Crackle Where Grains Split Into Smaller Bursts at the End of Their Life
Open in a new tab

07 · A Two-Layer Crackle Where Grains Split Into Smaller Bursts at the End of Their Life — Measured area 65.167% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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;
    }

08A Volley That Tints the Sky and Lights a Skyline Brighter Near Each Burst

Each burst is logged as a light source, and every building band adds up the brightness from all sources, each weighted by (1 − age/60)² / (1 + (d/90)²), so a source gives half its light at 90px. One volley fires four shells in a row and then rests, and the light of earlier shots is still alive when the later ones go off, so the sky and the buildings get brighter as the shots overlap. The render measured 45.1% area and intensity 44.2, the lowest of the nine, with motion in 23/23 frames.

A Volley Where the Sky Tints at Each Burst and the Skyline Lights Up by Distance
Open in a new tab

08 · A Volley Where the Sky Tints at Each Burst and the Skyline Lights Up by Distance — Measured area 45.078% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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);
                    }

09A Hero Scene Where Pointer Wind Pushes the Smoke Cloud Aside Between Bursts

Smoke grains drift slowly along a noise direction, and the speed the pointer is moving at is added as wind to grains within 70px of it, fading as (1 − d/70)². When nobody touches the scene, a fake pointer sweeps 150px to each side of center so the smoke parting is on show from the start. The render measured 28.3% area and intensity 46.5 with motion in 23/23 frames.

A Hero Scene Where a Pointer Wind Pushes the Smoke While Fireworks Launch
Open in a new tab

09 · A Hero Scene Where a Pointer Wind Pushes the Smoke While Fireworks Launch — Measured area 28.317% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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;
    }

Where the Values Change the Feel — Grain Count, Drag, Ripple Size

Three constants change the look the most, and the apex error is a number worth checking even if you change nothing. The figures in the three subsections below come from calling the source functions in node on a fake DOM, not from the browser render, so they are read separately from the render table here.

Scene Cumulative area Mean intensity Frames moving
01 Sphere burst 23.0% 86.8 23/23
02 Apex launch 58.2% 76.6 23/23
03 Peony, willow, ring 28.3% 93.3 23/23
04 Additive trail 41.6% 72.1 23/23
05 Embers 25.0% 64.2 23/23
06 Water mirror 22.8% 85.8 23/23
07 Crackle 65.2% 75.2 23/23
08 Skyline light 45.1% 44.2 23/23
09 Smoke and wind 28.3% 46.5 23/23

Raising BURST_N from 90 to 240 takes cell occupancy from 90 to 227

Cut scene 01 at tick 80 into 6px cells and count the cells holding a grain: 90 grains occupy 90 cells, and 240 grains occupy 227. The mean distance from a grain to its nearest neighbor falls from 14.3px to 7.4px. At tick 30 the same counts are 88 against 210, and 8.8px against 4.6px. A sphere of separate dots fills in like a thin film.

Raising WILLOW_DRAG from 0.96 to 0.99 stretches the mean reach 2.26 times

Over 124 ticks, scene 03's willow grains fly 132.5px from the start on average at 0.96 and 299.5px at 0.99. The stage is 300px tall, so the strands hang almost to the floor. The closer the drag factor gets to 1, the less speed is lost per tick, so a step of 0.005 already shows in the length and the constant should be moved in small steps.

Raising WAVE_AMP from 3 to 10 lifts the largest row shift from 4.8px to 16.1px

In scene 06, the largest sideways shift among the 110 water rows over 120 ticks grows from 4.8px to 16.1px. A grain is about 5px wide, so the distortion goes from roughly one grain width to more than three, and the reflection stops looking like a burst. At tick 10 the top row moves from 1.2px to 4.0px, and the bottom row from -4.3px to -14.5px.

Target heights 60 through 190 keep the apex error under 0.3px

Scene 02 gives launch speeds of 7.23, 6.54, 5.75 and 4.60 for target y values of 60, 100, 140 and 190, and the bursts land at 60.3, 100.1, 140.1 and 190.1. Setting APEX_FRAC above 0.04 makes the shell burst before it reaches the apex, and the miss grows by about APEX_FRAC²·h. That constant is what keeps a burst at the spot you pressed.

Where It Breaks — Too Many Grains, a Paused Tab, Reduced-Motion Settings

Pushing on the nine scenes by hand, the first things to show strain are the grain count, the drag and the input handling. What follows comes from the constants in the source and node calls, and I did not time how much slower the browser gets.

With many grains, cells get shared and only the draw calls grow

In scene 01 with BURST_N at 240, 13 grains still share a cell with another grain at tick 80. Up to MAX_SHELLS of 5 shells can be alive at once, and if each grain costs one line and one circle, a frame needs 240 × 5 × 2 = 2,400 draw calls. Push DRAG to 1 or above and speed never shrinks, so grains that left the screen keep updating until GRAIN_LIFE runs out.

Willow drag near 1 keeps the math running off screen

In scene 03 at a WILLOW_DRAG of 0.99 the mean reach is 299px, and the fastest grains leave the 480×300 stage. The source does not remove a grain until its 124-tick life is over, so the tail math for off-screen grains goes on for just as long.

Embers are cut by a cap, and noise amplitude 1 stops looking like a flicker

Scene 05 spawns 4 embers per tick and trims the oldest once MAX_EMBERS of 420 is passed, so a longer LIFE_MS reaches that cap first (the default already averages about 360 alive, 4 per tick over roughly 90 ticks). At a NOISE_AMP of 1, brightness can swing all the way to zero, and it reads as an ember vanishing and reappearing instead of flickering.

The reflection calls drawImage once per water row on every frame

Scene 06 makes one drawImage call for each of the 110 rows of H − HORIZON_Y, and getImageData appears in none of the nine scenes. The sky source is rebuilt when devicePixelRatio changes. A grain that falls below the horizon is not in the sky source, so it makes no reflection.

With 1000 parents, 571 split, so each crackle comes out a different size

Calling splitTally(1000, 5) in node on scene 07 gave 571 splits (57.1%), 2,855 children (571 × 5) and 0 grandchildren. A real shell has 84 parents, so the split count wobbles around 46 and every burst crackles a little differently. Raise SPLIT_DEPTH to 2 and the children split too, so the grain count multiplies by SPLIT_P × CHILD_N = 2.75 at each split.

While four shots go out, the first shot's light is still alive

Scene 08 fires VOLLEY_N of 4 shots with a GAP_MS of 260ms, which is 16 ticks, between shots. The four shots go out across 48 ticks, from the first to the fourth, and FLASH_LIFE is 60 ticks, so the first shot's light is still there 12 ticks after the fourth goes off and all four lights overlap. It keeps at most 8 lights and 280 sparks, and drops the oldest beyond that.

Pointer speed rides on the refresh rate and stops when the pointer leaves the canvas

In scene 09, inp.vx is updated on every pointer event, so a high-refresh monitor makes the same hand movement look faster, and the source clamps it to ±12. setPointerCapture appears 0 times in the nine sources, so once the pointer leaves the canvas pointermove stops and so does the wind. touch-action: none is set only in scene 09, which lets a finger drag without scrolling the page, and the other eight use manipulation.

A tab that wakes up catches up by a capped amount

All nine sources limit the time released in one frame with Math.min(ts - lastTs, 160). After a long stay in a hidden tab, at most 10 ticks run at once, so the fireworks do not rush all at once, and the time spent hidden is dropped rather than caught up.

In scene 02, a press near the edge and the burst point differ

aimPoint pulls the horizontal position in by CLICK_PAD of 28 and holds the vertical position at GROUND_Y − 60 or higher. A press near the floor bursts at y 218, and a press at a corner bursts 28px inside. To go past those limits you would edit aimPoint.

To change one constant at a time and see where a burst falls apart, open the zip, and the password is r9k5d5jr. It holds each scene's index.html and style.scss, plus the React versions.

Three Variants and Accessibility

Name Value Feel
Change the grain count 01 BURST_N 90 → 240 After 80 ticks the grains in distinct 6px cells go from 90 to 227 and the mean nearest-neighbor distance from 14.3px to 7.4px, so a sphere of dots fills in like a thin film.
Change the willow drag 03 WILLOW_DRAG 0.96 → 0.99 The mean distance flown in 124 ticks grows from 133px to 299px, 2.3 times as far, and the strands hang as long as the stage is tall.
Change the ripple size 06 WAVE_AMP 3 → 10 The largest row shift goes from 4.8px to 16.1px, more than three grain widths, and the reflection scatters.

The three variants were measured by calling the source functions in node, the same way as the values section above.

Keyboard access covers all nine scenes. The canvas has tabindex="0" so Tab brings focus to it, and its aria-label carries the scene name. Enter and Space stand in for a press, and the arrow keys move a virtual pointer by KEY_STEP of 14, and preventDefault is attached to only those six keys so scrolling and Tab movement stay free. The status line for screen readers is one <p> with aria-live="polite", rewritten only when the scene's own string changes.

With the prefers-reduced-motion setting on, none of the nine scenes starts a requestAnimationFrame loop, and each shows a still first frame. The pause button slot becomes a Next scene button, and each press advances the fireworks by 0.9 seconds of simulation. Flashes are toned down separately: scene 01's center flash goes from alpha 0.8 to 0.35, scene 04's glow from 0.85 to 0.6, scene 05 multiplies NOISE_AMP by 0.4, scene 06 freezes its ripple clock, and scene 08 sets gain to 0.5. The still frame keeps sparks in it, so nobody is left looking at an empty stage.

Is a Pure CSS Fireworks Animation Enough?

A pure css fireworks animation can work when a burst has a handful of fixed dots, and it stops being practical here because each scene needs per-grain speed and trail history. The nine scenes were tried only inside their own stage and never laid over a whole page. If you do lay one over a page, the first thing to check is whether the canvas swallows clicks meant for the elements underneath, and scenes 02 and 09, which take input, are the ones to test.

The frame around the stage is only its size. .sc is width: min(92vw, calc(160vh - 12.8vw)) with aspect-ratio: 480 / 300, and coordinates are always the 480×300 logical ones, so the scene math stays the same when the window resizes. For one-off paper pieces without grain math, 9 CSS Confetti Animation & Sparkle Effects is lighter, a look at fire as a calculation continues in 9 Canvas Fire Animation Scenes, No WebGL, and a backdrop of flying stars is in CSS Starfield: 9 Backgrounds Stars Fly Through. The calculation basis was checked against the requestAnimationFrame and globalCompositeOperation documentation.

Frequently Asked Questions Before Reusing CSS Fireworks Codepen Code

Can a css fireworks background run on keyframes alone?

Not in this set. All nine scenes are canvas, and CSS only handles the stage size, the aspect ratio, touch-action and the button look. A version with one hand-written keyframe per grain was never built.

Where does it get heavy first when I add more grains?

Reading the source, scene 01 draws a line and a circle per grain, so its cost follows the grain count directly, while scene 06 has a fixed 110 drawImage calls per frame whatever the grain count. I did not time the frames after adding grains.

Does the firework size change with the screen size?

Yes. Scene coordinates are fixed at 480×300 and the stage grows and shrinks with the viewport, so on a phone the grains are drawn that much smaller. The internal resolution follows devicePixelRatio up to 2, and fit() resets it when the size changes.

Enter the archive password

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