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

CSS Dither: 9 Scenes Drawn by Threshold Math

A css dither throws away every color but two, so why does the gradient look smoother?

Run All Nine Dithers Right on This Page

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

CSS Dither: 9 Scenes Where Thresholds, Error and Normals Turn Dots into Pictures

Each scene fills a small grid of 160 by 100 dots, one color per dot, and then stretches that grid three times wider onto the stage. The stretch runs with imageSmoothingEnabled off and image-rendering: pixelated in CSS, so a dot stays a hard square instead of blurring. The stage width is min(92vw, calc(160vh - 12.8vw)), and in the render capture window it came out about 419px wide.

Dithering means cutting brightness down to a few levels without letting the rounding error pile up into bands. There are two ways to scatter that error. One compares each cell's brightness with a threshold chosen in advance (scenes 01, 04, 05, 06, 07, 08 and 09), and the other hands the leftover from rounding to neighboring cells (02 and 03). The scenes start with those two methods bare (01 and 02), put one of them on a sphere (03), and then move on to a transition, ripples, a solid, a palette, type and pointer reaction.

Pick a starting point by the job. A loading background begins at 01, a moon-like sphere at 03, a page transition at 04, a click-reactive water hero at 05, and a first screen that lights up only around the cursor at 09. The same per-pixel canvas calculation turns into color gradients in Rainbow Gradient Background CSS: 9 Patterns, while this page cuts one brightness field down to two or four colors.

Code for Each Technique Behind the CSS Dither Effect

Every snippet below is a run of consecutive lines from the <script> in one scene's index.html, and none goes past 25 lines. Whatever gets filed under css image dithering rests on one of two calculations here: comparing against a threshold or passing error to neighbors.

01A Bayer Matrix Ordered Dither That Stamps a Striped Gradient in Two Colors

Each dot's brightness comes from a sine stripe running along the diagonal, the matrix threshold is added, and the dot turns on once the sum reaches 1. The matrix starts at 2 by 2 and doubles until it hits BAYER_SIZE, then dot (x, y) reads entry (y % n)·n + (x % n), which suits retro game style loading backgrounds. The render measured 74.8% cumulative area, mean intensity 182.8 and 23/23 frames moving.

An Ordered Dither That Stamps a Moving Gradient in Two Colors with a Bayer Matrix
Open in a new tab

01 · An Ordered Dither That Stamps a Moving Gradient in Two Colors with a Bayer Matrix — Measured area 74.844% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BAYER_SIZELEVELSSWEEP_SPEEDTHRESHOLD_BIAS
    // Bayer threshold matrix: n is a power of 2, and values are kept above 0 and below 1
    function makeBayer(n) {
        let size = 2;
        let m = [0, 2, 3, 1];
        const quad = [0, 2, 3, 1];
        while (size < n) {
            const s2 = size * 2;
            const next = [];
            for (let y = 0; y < s2; y += 1) {
                for (let x = 0; x < s2; x += 1) {
                    const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
                    next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
                }
            }
            m = next;
            size = s2;
        }
        const out = new Float32Array(n * n);
        for (let i = 0; i < out.length; i += 1) {
            out[i] = (m[i] + 0.5) / (n * n);
        }
        return out;
    }

02Error Diffusion That Passes Rounding Error to Neighbors for a Sine Plasma

Four summed sines make the brightness field, and a dot turns on when its value plus the carried error is above 0.5. The rounding leftover goes to four neighbors, 7/16 to the next dot along the row, 5/16 straight below, 3/16 below-behind and 1/16 below-ahead, and odd rows run right to left because SERPENTINE is 1, which suits a hero background filled with just two colors. This scene came out at 74.8% cumulative area, intensity 165.7 and 23/23 frames.

Error Diffusion That Passes Rounding Error to Neighbors to Draw a Sine Plasma
Open in a new tab

02 · Error Diffusion That Passes Rounding Error to Neighbors to Draw a Sine Plasma — Measured area 74.85% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

DIFFUSEFIELD_FREQPHASE_SPEEDSERPENTINE
                    const dir = (SERPENTINE === 1 && (y & 1) === 1) ? -1 : 1;
                    for (let k = 0; k < GW; k += 1) {
                        const x = dir > 0 ? k : GW - 1 - k;
                        const i = y * GW + x;
                        const val = field(x, y, ph, f) + err[i];
                        const on = val > 0.5 ? 1 : 0;
                        buf.px[i] = on === 1 ? light : dark;
                        // Floyd-Steinberg: 7/16 beside, 5/16 below, 3/16 and 1/16 diagonally below
                        const e = (val - on) * carry;
                        const hasNext = x + dir >= 0 && x + dir < GW;
                        const hasPrev = x - dir >= 0 && x - dir < GW;
                        if (hasNext) {
                            err[i + dir] += e * 7 / 16;
                        }
                        if (y + 1 < GH) {
                            if (hasPrev) {
                                err[i + GW - dir] += e * 3 / 16;
                            }
                            err[i + GW] += e * 5 / 16;
                            if (hasNext) {
                                err[i + GW + dir] += e * 1 / 16;
                            }
                        }
                    }

03Sphere Shading Where an Orbiting Light Turns Moon Phases into Dots

The dot product of the sphere's surface normal and the light direction becomes the brightness, and Atkinson diffusion stamps it. That method gives only 1/8 of the error to each of six neighbors and throws a quarter away, so the dark side and the bright side harden into clean colors while the light phase turns into dot density, which fits moon phase widgets and night mode decoration. The numbers were 30.4% cumulative area, intensity 188.2 and 22/23 frames.

Sphere Shading Where an Orbiting Light Turns Moon Phases into Dots
Open in a new tab

03 · Sphere Shading Where an Orbiting Light Turns Moon Phases into Dots — Measured area 30.397% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

LIGHT_SPEEDSPHERE_RAMBIENTATKINSON_KEEP
                            const nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny));
                            // the dot product of normal and light direction is the brightness
                            v = clamp(Math.max(0, nx * lx + ny * ly + nz * lz) + AMBIENT, 0, 1);
                        }
                        else {
                            v = HALO_GAIN * lit * Math.exp(-(r - SPHERE_R) / HALO_W);
                            if (sx.has(i) && r > SPHERE_R + 4) {
                                v = Math.max(v, 0.9);
                            }
                        }
                        const val = v + err[i];
                        const on = val > 0.5 ? 1 : 0;
                        buf.px[i] = on === 1 ? light : dark;
                        // Atkinson: six neighbors get only 1/8 of the error each, and the rest is dropped
                        const e = (val - on) * ATKINSON_KEEP;
                        addErr(i + 1, x + 1, y, e);
                        addErr(i + 2, x + 2, y, e);
                        if (y + 1 < GH) {
                            addErr(i + GW - 1, x - 1, y + 1, e);
                            addErr(i + GW, x, y + 1, e);
                            addErr(i + GW + 1, x + 1, y + 1, e);
                        }
                        if (y + 2 < GH) {
                            addErr(i + 2 * GW, x, y + 2, e);
                        }

04An Interleaved Noise Transition That Dissolves One Scene into the Next as Dots

Every dot gets a pseudo-random threshold from its coordinates alone, and where that threshold is below the progress p the stripe scene shows, otherwise the ring scene. The thresholds spread evenly, so dots flip in a scatter as p climbs, and that suits page transitions and slide changes. It is the widest of the nine at 75.3% cumulative area, with intensity 111.8 and 23/23 frames.

An Interleaved-Noise Transition That Dissolves One Scene into the Next as Dots
Open in a new tab

04 · An Interleaved-Noise Transition That Dissolves One Scene into the Next as Dots — Measured area 75.253% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

NOISE_ANOISE_BFADE_PERIODHOLD_FRAMES
                        // interleaved gradient noise: a 0-to-1 value set by the coordinates alone
                        const thr = frac(52.9829189 * frac(NOISE_A * x + NOISE_B * y));
                        if (thr < p) {
                            const s = Math.sin((x * 0.7 + y * 0.7) * STRIPE_FREQ + t * 0.1);
                            buf.px[y * GW + x] = mixPack(STRIPE_DARK, STRIPE_LIGHT, smooth(s * 1.5 + 0.5));
                        }
                        else {
                            const s = Math.sin(Math.hypot(x - GW * 0.35, y - GH * 0.55) * RING_FREQ - t * 0.12);
                            buf.px[y * GW + x] = mixPack(ringLo, ringHi, smooth(s * 1.5 + 0.5));
                        }

05A Dithered Pond Where Wave Equation Ripples Are Stamped in Four Tones

The height field updates from the sum of four neighbors and the previous frame, which spreads the ripples, and the height is then split into four tones and stamped through an 8 by 8 Bayer matrix. Moving the pointer drops a droplet every 9 ticks and pressing drops a big one, which suits water heroes and click-reactive backgrounds. Here the render gave 63.0% cumulative area, intensity 84.6 and 23/23 frames.

A Dithered Pond Where Wave-Equation Ripples Are Stamped in Four Tones
Open in a new tab

05 · A Dithered Pond Where Wave-Equation Ripples Are Stamped in Four Tones — Measured area 62.954% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

DAMPINGWAVE_CLEVELSDROP_EVERY
        // one wave-equation step: next height from the neighbor sum and the previous frame
        function simulate() {
            for (let y = 1; y < GH - 1; y += 1) {
                for (let x = 1; x < GW - 1; x += 1) {
                    const i = y * GW + x;
                    const lap = h[i - 1] + h[i + 1] + h[i - GW] + h[i + GW] - 4 * h[i];
                    hn[i] = (2 * h[i] - hp[i] + WAVE_C * lap) * DAMPING;
                }
            }
            const old = hp;
            hp = h;
            h = hn;
            hn = old;
        }

06A 3D Dither Cube That Turns Face Brightness into Dot Density

Face centers and normals are rotated, only the faces turned toward the camera are kept, and corners are projected with x·FOCAL/(z+CAM_DIST). Each face's brightness comes from its angle to the light and is compared with Bayer thresholds, and since a convex solid never shows overlapping faces, no depth sorting is needed, which suits rotating decoration around a logo. The render measured 26.4% cumulative area, intensity 147.3 and 23/23 frames.

A 3D Dither Cube That Turns Face Brightness into Dot Density
Open in a new tab

06 · A 3D Dither Cube That Turns Face Brightness into Dot Density — Measured area 26.445% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ROT_SPEEDLIGHT_DIRCAM_DISTFACE_SHADE
            const n = rot(f.n, a, b);
            const c = rot([f.n[0] * SIDE, f.n[1] * SIDE, f.n[2] * SIDE], a, b);
            // draw only the faces turned toward the camera at (0, 0, -CAM_DIST)
            const toCam = n[0] * (0 - c[0]) + n[1] * (0 - c[1]) + n[2] * (-CAM_DIST - c[2]);
            if (toCam <= 0) {
                return;
            }
            const signs = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
            const pts = [];
            for (let k = 0; k < 4; k += 1) {
                const s = signs[k];
                const corner = rot([
                    (f.n[0] + s[0] * f.u[0] + s[1] * f.v[0]) * SIDE,
                    (f.n[1] + s[0] * f.u[1] + s[1] * f.v[1]) * SIDE,
                    (f.n[2] + s[0] * f.u[2] + s[1] * f.v[2]) * SIDE
                ], a, b);
                const k2 = FOCAL / (corner[2] + CAM_DIST);
                pts.push([GW / 2 + corner[0] * k2, GH / 2 - corner[1] * k2]);
            }
            const lam = Math.max(0, n[0] * lx + n[1] * ly + n[2] * lz);
            const shade = FACE_SHADE + (1 - FACE_SHADE) * lam;

07A Palette Dithered Sunset That Quantizes a Sky Gradient to Four Colors

A sky gradient, a halo around the sun and a sea reflection are built first, and then each pixel's color is nudged by a Bayer value and snapped to the nearest of four palette colors. The size of that nudge is DITHER_AMP, so color edges read as dots instead of bands, which suits landing backgrounds with a low-res look. It measured 44.5% cumulative area, intensity 79.9 and 23/23 frames.

A Palette-Dithered Sunset That Quantizes a Sky Gradient to Four Colors
Open in a new tab

07 · A Palette-Dithered Sunset That Quantizes a Sky Gradient to Four Colors — Measured area 44.511% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

DITHER_AMPPALETTE_SIZEHORIZON_YSUN_SPEED
        // index of the palette color nearest to the adjusted color
        function nearest(r, g, b) {
            let best = 0;
            let bestD = 1e9;
            for (let k = 0; k < pal.length; k += 1) {
                const dr = r - pal[k][0];
                const dg = g - pal[k][1];
                const db = b - pal[k][2];
                const d = dr * dr + dg * dg + db * db;
                if (d < bestD) {
                    bestD = d;
                    best = k;
                }
            }
            return best;
        }

08Type That Scatters into Dots and Gathers Again as a Dither

The word is drawn on a hidden canvas, read once with getImageData, and blurred by a 3 by 3 average into a brightness mask. Every frame that mask is compared with a threshold drifting over time, so dots are erased and restored from the left, which suits title and logo reveals. At 21.9% cumulative area it is the narrowest of the nine, with intensity 136.9 and 23/23 frames.

Type That Scatters into Dots and Gathers Again as a Dither
Open in a new tab

08 · Type That Scatters into Dots and Gathers Again as a Dither — Measured area 21.874% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

DISSOLVE_SPEEDFONT_PXMASK_BLURBAYER_SIZE
        const mask = new Float32Array(GW * GH);
        const off = document.createElement('canvas');
        off.width = GW;
        off.height = GH;
        const octx = off.getContext('2d');
        if (octx) {
            octx.fillStyle = '#000000';
            octx.fillRect(0, 0, GW, GH);
            octx.fillStyle = '#ffffff';
            octx.font = 'bold ' + FONT_PX + 'px sans-serif';
            octx.textAlign = 'center';
            octx.textBaseline = 'middle';
            octx.fillText(WORD, GW / 2, GH / 2);
            const data = octx.getImageData(0, 0, GW, GH).data;
            const raw = new Float32Array(GW * GH);
            for (let i = 0; i < raw.length; i += 1) {
                raw[i] = data[i * 4] / 255;
            }

09A Dither Hero Where Only the Area Around the Pointer Gets Dense

Noise drives the background brightness, a Gaussian of the distance to the pointer is added, and the sum is compared with a Bayer threshold, so dots pack densely only around the cursor. The pointer position is chased slowly through LERP, so the halo glides, and that makes it a good focal scene for a hero. It gave 50.6% cumulative area, intensity 91.9 and 23/23 frames.

A Dither Hero Where Only the Area Around the Pointer Gets Dense
Open in a new tab

09 · A Dither Hero Where Only the Area Around the Pointer Gets Dense — Measured area 50.6% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

HALO_RHALO_GAINNOISE_SCALELERP
                const breathe = 1 + 0.12 * Math.sin(t * 0.07) + 0.6 * smooth(pulse / PULSE_TICKS);
                const sigma = (HALO_R / 1.6) * breathe;
                const inv = 1 / (2 * sigma * sigma);
                for (let y = 0; y < GH; y += 1) {
                    for (let x = 0; x < GW; x += 1) {
                        const nx = x * NOISE_SCALE + t * 0.012;
                        const ny = y * NOISE_SCALE - t * 0.007;
                        const n = vnoise(nx, ny, NOISE_SEED) * 0.65 + vnoise(nx * 2.1 + 7, ny * 2.1 + 3, NOISE_SEED) * 0.35;
                        const dx = x - hx;
                        const dy = y - hy;
                        const halo = Math.exp(-(dx * dx + dy * dy) * inv);
                        const v = 0.05 + 0.5 * n + halo * HALO_GAIN;
                        const i = y * GW + x;
                        buf.px[i] = v > matrix[(y & 7) * 8 + (x & 7)] ? tint[Math.min(31, Math.floor(halo * 31))] : dark;

Where the Values Change the Dot Pattern — Matrix Size, Error Ratio, Ripple Damping

Three constants change the dot pattern in a way you can see. The numbers in this part did not come from the browser render; they come from running the same <script> calculation in node against a fake DOM, so read them apart from the table further down.

Growing the Matrix from 4 to 8 Lifts the Tile Count Kinds from 17 to 44

Set BAYER_SIZE in scene 01 from 4 to 8 and look at one frame after 30 ticks. The number of distinct on-dot counts across matrix-sized tiles rises from 17 to 44, against a theoretical ceiling that rises from 17 to 65, while the lit fraction stays at 0.520 versus 0.523. Brightness holds and the texture resolves finer, at the price of a repeat period that grows from 4 cells to 8.

Lowering the Carry Ratio Clumps the Lit Dots

Dropping DIFFUSE in scene 02 from 1.0 to 0.75 moves the average run of lit dots along a row from 1.88 cells to 2.11, averaged over three frames. At 0.5 the run is 2.67 cells, and at 0 the dots lock into a band 36.3 cells long. The lit fraction hardly moves, 0.529 versus 0.533.

Raising the Damping Keeps Big Ripples on Screen

Raising DAMPING in scene 05 from 0.985 to 0.995 lifts the share of pixels stamped in the darkest or brightest tone from 6.0% to 27.1%, averaged over 8 frames. Crests and troughs pile up instead of fading away.

The nine full renders returned the figures below. Scene 04 spread widest at 75.3% and scene 08 narrowest at 21.9%, and only scene 03 dropped to 22/23 frames, a cause that was not tracked down here.

Scene Cumulative area Mean intensity Frames moving
01 Ordered dither 74.8% 182.8 23/23
02 Error diffusion 74.8% 165.7 23/23
03 Sphere shading 30.4% 188.2 22/23
04 Noise transition 75.3% 111.8 23/23
05 Dither pond 63.0% 84.6 23/23
06 3D cube 26.4% 147.3 23/23
07 Palette sunset 44.5% 79.9 23/23
08 Type dither 21.9% 136.9 23/23
09 Pointer halo 50.6% 91.9 23/23

Where It Breaks — High Resolutions, Narrow Screens, Reduced-Motion Settings

The Dot Grid Stays 160 by 100 at Any Resolution

Raising the screen resolution leaves the calculated cells at 160 by 100, and only the canvas's internal size follows devicePixelRatio, capped at 2. Scene 09 runs two vnoise calls and one exponential over 16,000 cells, so its frame is heavier than the others. A bigger grid would cost proportionally more, though no larger grid was timed here.

Error Diffusion Is Sequential

Each dot in scene 02 depends on the error handed down by the dot before it, so cells cannot be processed side by side. Every frame calls err.fill(0) and walks all 16,000 cells again from the start. Nothing is carried between frames, so a moving field rearranges the whole dot layout. That is why the grid is kept small and tripled in size.

Atkinson Throws Away a Quarter of the Error

With the light facing the sphere, scene 03 has 3,975 lit dots, 1,251 after 31 ticks, and 15 after 63 ticks when the phase reaches the far side. A few remain at the darkest point because the 26 background stars, placed with the seeded lcg(453303), are lit when they sit far from the sphere. Dropping a quarter of the error is what lets the dark and bright sides harden into pure colors.

The Transition Threshold Has No Time Term

Scene 04 builds its threshold from coordinates only, so the same p always picks the same dots. With thr < p, the share of dots taken at p of 0.25, 0.5 and 0.75 came out as 0.250, 0.500 and 0.750, equal to p. The longest horizontal run of stripe-side dots measured 1 cell at p 0.25, 2 cells at 0.5 and 6 cells at 0.75, so the dots scatter rather than clump.

Raising the Wave Coefficient Makes the Calculation Diverge

Raising WAVE_C in scene 05 from 0.4 to 0.6 gives an extreme-tone share of 0.929 over the same 8 frames, and the tone kinds grow to 5, so values outside the four tones get mixed in. Edge cells are not calculated and stay at 0, and damping is multiplied twice per tick, which makes the real per-tick damping the square of DAMPING.

The Cube Is Clipped When Its Constants Grow

The earlier constants in scene 06 (FOCAL 125, CAM_DIST 3.8, SIDE 0.85) gave an edge-pixel bounding box of 98 by 97 that touched the top of the grid and got cut off. The current values (FOCAL 112, CAM_DIST 4.2, SIDE 0.8) give 71 by 75 and stay inside the grid for 125 ticks. At most three faces are visible at once, because toCam is zero or negative for every face turned away from the camera and those faces are skipped. Scene 06 calls both makeBayer(8) and makeBayer(4).

A Dither Amplitude of 0 Hardens Edges into Bands

In scene 07, the number of vertically adjacent pixel pairs with different colors is 10,921 at an amplitude of 0.4 and 1,523 when DITHER_AMP is 0. That is about 7 times fewer, and it shows why the dither is there at all. The 4-color palette also means four distance calculations per pixel.

Glyph Width Is Decided by the Device Font

Scene 08 draws with bold 38px sans-serif and never calls measureText for the width. If a device's default font runs wider than the 160 cell grid, both ends could be cut off, but no other font was tried here. When the canvas context is unavailable the mask stays 0 and no letters appear.

On Narrow Screens Only the Text Shrinks

At 340px and below, the caption, button and status text drop to 10px while the canvas keeps its 480:300 ratio. The stage width follows the viewport, so on a phone each dot simply looks smaller.

To edit the values and watch the edges move, you need the files. Unlock the archive with the password ee8ur62p, and inside you will find each scene's index.html and style.scss along with a React port of all nine. Every file is plain text, so you can change a constant, reload, and watch the dots shift right away.

Three Variants and Accessibility

Variant Value Feel
Change the matrix size 01 BAYER_SIZE 4 → 8 Tile on-dot counts go from 17 kinds to 44, the texture resolves finer, and the lit fraction stays near 0.52.
Change the error carry ratio 02 DIFFUSE 1.0 → 0.75 The average lit run goes from 1.88 to 2.11 cells, so the dots clump slightly.
Change the ripple damping 05 DAMPING 0.985 → 0.995 Extreme-tone pixels rise from 6.0% to 27.1% on average, and ripples linger.

Each of these three figures was measured the same way as in the values part, by running the source in node.

All nine scenes use the same controls. Each has one <button>, and its canvas carries tabindex="0" so Tab reaches it. Enter and Space trigger the scene's press action, and the arrow keys move a virtual pointer 14 units at a time. preventDefault is called only for those six keys, and every other key, Tab included, passes through. The status line lives in a <p> with aria-live="polite" and is rewritten only when its text changes.

With prefers-reduced-motion turned on, none of the nine scenes starts its requestAnimationFrame loop. At startup each one calls advance(1400), running 1,400 ms of motion ahead, and paints one still frame, and the dot pattern reads fine while still. The button becomes a "next scene" control that advances REDUCE_STEP_MS (900ms) of motion per press, and any pointer or key input advances 6 ticks and redraws at once, so input shows up on screen right away.

Is a CSS Dither Filter Alone Enough?

If you search for a css dither filter you may expect a one-line property, but these nine scenes stamp dots into a canvas with putImageData, so the CSS only keeps scaled-up dots from blurring through image-rendering: pixelated and sets the stage size. The work of comparing thresholds or passing error along happens in script.

So the first question is whether you need an effect laid over an existing photo or element, or whether you can compute the brightness field yourself and turn it into dots. If it is the second, these nine are a place to start. For the same canvas calculation in 3D, 9 Ray Marching Scenes in Canvas (No WebGL) comes next, and for flames drawn as particles there is 9 Canvas Fire Animation Scenes, No WebGL. How the canvas call works is covered in MDN's putImageData page.

Frequently Asked Questions Before Reusing a CSS Dither Gradient

How do I point css image dithering at my own photo?

The nine scenes read no photo, since they build their brightness from formulas. Scene 08 is the one place that reads a drawn picture with getImageData and uses it as a brightness array, so a photo read the same way would supply those values. No photo was swapped in and run here.

Can the grid be bigger than 160 by 100?

The work grows with the cell count. Scene 02 walks 16,000 cells in order from the start each frame, and scene 09 computes noise twice per cell, so those two are the heavy ones. No larger grid was timed here.

Do I need a library or WebGL?

No. None of the nine files uses a library, a CDN or WebGL, and none calls Math.random. Scenes 03 and 05 draw their random numbers from a seeded lcg, scene 04 uses a coordinate hash and scene 09 uses vnoise, so the same tick draws the same frame on every run.

Enter the archive password

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