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

CSS Captcha: 9 Warped Text and Rotate Checks

Call it a human check, but a css captcha is arithmetic on screen — scene 01 slides glyph columns along a sine wave and scene 07 raises a noise threshold until

Solve All Nine Human-Check Scenes Right on This Page

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

CSS Captcha: 9 Scenes Built From Column Shifts, Angle Snap and Threshold Raising

The nine scenes are drawn on a <canvas> 2D context. How far a letter bends, which way a tile's stripes lean, and what value a noise cell holds change pixel by pixel and coordinate by coordinate, and a few keyframes cannot describe that. So the CSS keeps only the outer frame: the stage ratio, rounded corners, touch-action, and focus outlines.

The nine files share a single mount harness. It splits the timestamp handed over by requestAnimationFrame into fixed FIXED_DT ticks of 16ms, and random numbers come only from the seeded lcg and hash2. No source among the nine contains Math.random, Date.now, or fetch, so the same seed brings back the same puzzle. Scene 06 is the odd one out: it defines lcg but builds its scene without a seed.

The grid cells that solve and fail on their own are not recordings. After 150 ticks (2.4 seconds) without input, a demo script feeds input through the same entry points a hand would use (down, move, up, key) and works the puzzle, and it stops the moment a person touches the canvas.

One line needs drawing clearly. The tolerance angle, the set of correct tiles, the pass score, and the miss count are demo rules inside this page, and there is no server code, token, or verification request anywhere. Any answer shipped to the browser can be read by whoever opens the source, so on a live site the server issues the challenge and judges the answer, and these scenes fill the screen space that shows the result.

It saves time to pick a starting scene by where it will end up. For a sign-up form checkbox, start at 01 or 06; a photo orientation step suits 02, an image-picking board 03, and a short press-in-order puzzle 04. A trace-the-line step before a signature box is 05, and if you are after a captcha css game with a score on screen, look at 05 and 04 together. A find-the-letters-in-noise screen is 07, a check that looks harder as failed logins pile up is 08, and a challenge widget with a time limit is 09.

Code for Each Technique Behind a Captcha CSS Animation

Each snippet is an unbroken run of lines from that scene's index.html, 25 lines at most, with its comments translated into English. In most scenes one function in the snippet decides the check, and the drawing code only paints whatever that function returns. Identifiers match the source letter for letter, so you can search the zip for them.

01A Glyph Board Where Each Pixel Column Slides Along a Sine Wave and the Letters Bend

A 240×150 work canvas gets the letters and slanted stripes baked once and kept with getImageData, then on each frame warpPixels walks all 36,000 pixels and pulls each one from a source spot moved by two sines: a horizontal shift that depends on the row and a vertical shift that depends on the column. Coordinates that fall off the board are clamped to the edge pixel, and the result goes back with putImageData, is doubled to 480×300, and gets four wobbling Bezier lines on top. In the render it covered 57.5% of the stage, the third largest of the nine, and all 23 of 23 frame gaps changed (intensity 44.3).

A Glyph Board Where Each Pixel Column Slides Along a Sine Wave and the Letters Bend
Open in a new tab

01 · A Glyph Board Where Each Pixel Column Slides Along a Sine Wave and the Letters Bend — Measured area 57.532% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

WARP_AMPWARP_FREQNOISE_LINESGLYPH_PX
    function warpPixels(src, out, lw, lh, amp, freq, ph) {
        for (let y = 0; y < lh; y += 1) {
            const dx = Math.round(amp * 0.6 * Math.sin(y * freq * 1.3 + ph * 1.2));
            for (let x = 0; x < lw; x += 1) {
                const dy = Math.round(amp * Math.sin(x * freq + ph));
                let sx = x + dx;
                let sy = y + dy;
                sx = sx < 0 ? 0 : (sx >= lw ? lw - 1 : sx);
                sy = sy < 0 ? 0 : (sy >= lh ? lh - 1 : sy);
                const si = (sy * lw + sx) * 4;
                const di = (y * lw + x) * 4;
                out[di] = src[si];
                out[di + 1] = src[si + 1];
                out[di + 2] = src[si + 2];
                out[di + 3] = 255;
            }
        }
    }

02A Check Where a Dial Turns a Tilted Landscape Upright and a Spring Snaps It In Once the Angle Is Close

The dial angle times DIAL_GAIN of 1.5 turns the whole landscape, and on release snapCorrection folds the angle into −180 to 180 and returns a correction when it sits within the 10° SNAP_DEG tolerance, or null when it does not. springStep then pulls that correction to zero in 16ms steps with SPRING_K 190 and DAMP 15, and fed an 8° correction in Node, it overshoots to 8.86° (10.7%) and settles at 512ms. Change showed in 21 of 23 frames, with 21.3% cumulative area and intensity 56.5.

A Check Where a Dial Turns a Tilted Landscape Upright and a Spring Snaps It In Once the Angle Is Close
Open in a new tab

02 · A Check Where a Dial Turns a Tilted Landscape Upright and a Spring Snaps It In Once the Angle Is Close — Measured area 21.267% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

SNAP_DEGDIAL_GAINSPRING_KDAMP
    // Fold the angle into -180 or more and under 180
    function wrapDeg(d) {
        let r = (d + 180) % 360;
        if (r < 0) {
            r += 360;
        }
        return r - 180;
    }
    // Inside the tolerance, the correction (deg) that stands it upright; outside, null
    function snapCorrection(theta, snapDeg) {
        const d = wrapDeg(theta);
        return Math.abs(d) <= snapDeg ? -d : null;
    }
    // One spring step (semi-implicit Euler) - returns position and velocity
    function springStep(pos, vel, target, k, damp, dt) {
        const a = -k * (pos - target) - damp * vel;
        const v = vel + a * dt;
        return [pos + v * dt, v];
    }

03A 3×3 Tile Board Where Picking the Matching Pattern Flips the Tile Over to Mark It

tileAngles picks the reference angle from twelve steps of 15° by seed, gives the three matching tiles angles within ±6° of it, and pushes the other six away by ANGLE_TOL × 1.6 plus 0 to 60°. Running seeds 1 through 300, the widest gap on a matching tile is 5.99° and the narrowest on a non-matching tile is 19.20°, so the 12° tolerance fits cleanly between them; a pressed tile flattens by scaleY = |cos(πp)| before turning over to show its back. It moved in only 15 of 23 frames, the fewest of the nine, yet the flips averaged intensity 104.2 over 35.2% of the area.

A 3×3 Tile Board Where Picking the Matching Pattern Flips the Tile Over to Mark It
Open in a new tab

03 · A 3×3 Tile Board Where Picking the Matching Pattern Flips the Tile Over to Mark It — Measured area 35.173% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

TILE_NFLIP_MSMATCH_MINSHIMMER
        const angles = [];
        const match = [];
        for (let i = 0; i < total; i += 1) {
            angles.push(0);
            match.push(false);
        }
        for (let k = 0; k < total; k += 1) {
            const i = order[k];
            if (k < nMatch) {
                angles[i] = target + (r() - 0.5) * tol;
                match[i] = true;
            }
            else {
                const sign = r() < 0.5 ? -1 : 1;
                angles[i] = target + sign * (tol * 1.6 + r() * 60);
            }
        }
        return { target: target, angles: angles, match: match };
    }

04A Check Where Pressing Scattered Glyphs in Order Draws a Joining Line

placeGlyphs sets four glyphs at least 92px apart, and pressing them in the order shown on the top line grows the line head with growStep, which adds 16% of the remaining distance plus 0.012 per tick, so it reaches 0.99 in 15 ticks (240ms). Break the order and a dashed pink try-line shoots out and returns, then the joined line rewinds from its end at 0.1 per tick until it is back at the first glyph. Its cumulative area stayed at 17.9%, but its mean intensity of 111.4 was the highest of the nine, with motion in 23/23 frames.

A Check Where Pressing Scattered Glyphs in Order Draws a Joining Line
Open in a new tab

04 · A Check Where Pressing Scattered Glyphs in Order Draws a Joining Line — Measured area 17.943% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GLYPH_NHIT_RJITTERLINE_LERP
        function click(i) {
            if (rewind || i < 0) {
                return;
            }
            if (done) {
                // Pressing a fully joined board again moves on to a new board
                seed += 1;
                setup();
                return;
            }
            const want = order[chain.length];
            if (i === want) {
                chain.push(i);
                if (chain.length > 1) {
                    seg.push(0);
                }
                if (chain.length === GLYPH_N) {
                    done = true;
                }
            }
            else if (!chain.includes(i)) {
                att = { from: chain.length ? chain[chain.length - 1] : -1, to: i, t: 1 };
                rewind = true;
            }
        }

05A Check Where the Pointer Trail Drawn Along a Winding Guide Becomes a Score From Its Mean Distance to That Guide

The guide is a sum of two sines sampled every 4px across x 40 to 440 into 101 points, and each time the pointer moves 6px or more a sample is kept and nearest measures its distance to the closest guide point. scoreTrail turns the mean into round(100 × (1 − mean / (2 × TOL_PX))), so the pass mark of 60 means a mean distance of 9.6px or less, 0.8 times TOL_PX. The traced path covered only 12.9% of the stage, and the drawing showed up in 23/23 frames at intensity 91.6.

A Check Where the Pointer Trail Drawn Along a Winding Guide Becomes a Score From Its Mean Distance to That Guide
Open in a new tab

05 · A Check Where the Pointer Trail Drawn Along a Winding Guide Becomes a Score From Its Mean Distance to That Guide — Measured area 12.908% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GUIDE_AMPTOL_PXSCORE_PASSSAMPLE_STEP
    function nearest(px, py, guide) {
        let best = 1e9;
        for (let i = 0; i < guide.length; i += 1) {
            const dx = guide[i][0] - px;
            const dy = guide[i][1] - py;
            const d = dx * dx + dy * dy;
            if (d < best) {
                best = d;
            }
        }
        return Math.sqrt(best);
    }
    // Average each trail sample's distance to the nearest guide point and turn it into a score (0 points when the mean is twice the tolerance)
    function scoreTrail(samples, guide, tol) {
        if (samples.length === 0) {
            return { mean: 0, score: 0 };
        }
        let sum = 0;
        for (let i = 0; i < samples.length; i += 1) {
            sum += nearest(samples[i][0], samples[i][1], guide);
        }
        const mean = sum / samples.length;
        const score = Math.round(100 * Math.min(1, Math.max(0, 1 - mean / (tol * 2))));
        return { mean: mean, score: score };
    }

06A Check Button Where the Box Turns Into a Ring, Spins, and Resolves Into a Tick

A row runs through wait 140, box to ring 360, arc spin 560, hold 180, and ring back to a ticked box 360ms, and rowShape derives the corner radius (12 to 32px) and the rotation (0 to 90°) together from the cumulative times t1 to t5 and one easing value e. Halfway through the change, at e = 0.5, the radius is 22px and the turn 45°, and once the radius reaches 32px, half the 64px side, the square has become a circle. It covered 12.2% of the area, the second smallest of the nine, yet reached intensity 102.6 with change in 23/23 frames.

A Check Button Where the Box Turns Into a Ring, Spins, and Resolves Into a Tick
Open in a new tab

06 · A Check Button Where the Box Turns Into a Ring, Spins, and Resolves Into a Tick — Measured area 12.18% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RING_RSPIN_MSMORPH_EASEPASS_DELAY
        else if (u < t2) {
            e = easeInOutPow((u - t1) / morphMs, ease);
            phase = 1;
        }
        else if (u < t3) {
            e = 1;
            spin = (u - t2) / spinMs;
            phase = 1;
        }
        else if (u < t4) {
            e = 1;
            phase = 1;
        }
        else if (u < t5) {
            e = 1 - easeInOutPow((u - t4) / morphMs, ease);
            phase = 1;
            check = clamp01((u - t4) / morphMs - 0.35) / 0.65;
        }
        else {
            e = 0;
            check = 1;
            phase = 2;
        }
        return { r: boxR + (ringR - boxR) * e, rot: e * Math.PI * 0.5, e: e, spin: spin, check: check, phase: phase, total: t5 };
    }

07A Check Where Letters Hidden in Noise Surface More Clearly as the Threshold Rises

For each of the 160×100 cells, fbm sums three layers of value noise, the letter mask times BOOST 0.42 is added, and litAmount lifts brightness from 0 to 1 inside a GRAIN band of 0.045 as the value passes the threshold. In a Node test of 40,000 samples, fbm exceeds 0.86 in 0.14% of them and 0.44 in 66.4%, so at a threshold of 0.86 the outside noise has nearly gone dark while about two thirds of the mask stays lit. At 75.7% it covered the most area of the nine, with motion in 23/23 frames and intensity 87.1.

A Check Where Letters Hidden in Noise Surface More Clearly as the Threshold Rises
Open in a new tab

07 · A Check Where Letters Hidden in Noise Surface More Clearly as the Threshold Rises — Measured area 75.716% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

THRESHNOISE_SCALEOCTAVESGRAIN
    function fbm(x, y, seed, oct) {
        let sum = 0;
        let amp = 1;
        let norm = 0;
        let f = 1;
        for (let o = 0; o < oct; o += 1) {
            sum += amp * vnoise(x * f + o * 17.3, y * f - o * 9.1, seed + o);
            norm += amp;
            amp *= 0.5;
            f *= 2;
        }
        return sum / norm;
    }
    // How far the value is past the threshold, as 0 to 1 (rises smoothly inside the grain width)
    function litAmount(v, thresh, grain) {
        const k = (v - thresh) / grain + 0.5;
        return k <= 0 ? 0 : (k >= 1 ? 1 : k * k * (3 - 2 * k));
    }

08A Stepped Check Where Warp Amplitude and Line Count Rise One Step per Miss

distortionFor sets the amplitude to min(13, 1.5 + 3 × fails) and the line count to min(6, 1 + fails), and the glyph board is cut into 75 strips 4px wide, each shifted up or down by a sum of two sines and pasted back with drawImage. The drawn amplitude ampNow closes 20% of the gap to its target per tick, so each miss bends the board further by degrees rather than in one jump. Through the demo's run of misses, 23/23 frames changed, with 59.7% area and intensity 79.5.

A Stepped Check Where Warp Amplitude and Line Count Rise One Step per Miss
Open in a new tab

08 · A Stepped Check Where Warp Amplitude and Line Count Rise One Step per Miss — Measured area 59.694% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ESCALATE_STEPAMP_MAXLINES_MAXFAIL_LIMIT
                const d = distortionFor(fails, ESCALATE_STEP, AMP_MAX, LINES_MAX);
                const ox = shake > 0 ? Math.sin(shake * 1.7) * shake * 0.5 : 0;
                // Glyph board - give each 4px vertical strip its own sine shift and paste it back
                ctx.save();
                ctx.beginPath();
                rrect(ctx, BX + ox, BY, BW, BH, 16);
                ctx.clip();
                ctx.fillStyle = rgba(PANEL, 1);
                ctx.fillRect(BX + ox, BY, BW, BH);
                for (let x = 0; x < BW; x += 4) {
                    const dy = ampNow * Math.sin(x * 0.05 + ph) + ampNow * 0.35 * Math.sin(x * 0.13 - ph * 1.7);
                    ctx.drawImage(src, x, 0, 4, BH, BX + ox + x, BY + dy, 4, BH);
                }

09A Check Where a Time Ring Shrinks Over the Challenge Lifetime and Then a Refresh Icon Spins to Swap In a New One

ringFrac clips 1 − elapsed / TTL_MS to the range 0 to 1 and uses it as the ring arc length, and warnMix climbs from 0 to 1 once the remaining ratio drops below WARN_FRAC 0.3, mixing the mint toward pink. At zero the refresh icon turns half a revolution over 360ms, then the seed moves on and the ring refills in 220ms, so one challenge lasts 1300 + 360 = 1660ms from a fresh ring to the next glyph board. Mostly the ring and the icon move, so its area was the smallest at 11.8%, with intensity 86.4 across 23/23 frames.

A Check Where a Time Ring Shrinks Over the Challenge Lifetime and Then a Refresh Icon Spins to Swap In a New One
Open in a new tab

09 · A Check Where a Time Ring Shrinks Over the Challenge Lifetime and Then a Refresh Icon Spins to Swap In a New One — Measured area 11.785% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TTL_MSWARN_FRACREFRESH_SPIN_MSRING_W
    // Remaining time ratio - becomes the ring arc length
    function ringFrac(elapsedMs, ttlMs) {
        const f = 1 - elapsedMs / ttlMs;
        return f < 0 ? 0 : (f > 1 ? 1 : f);
    }
    // Color mix ratio that climbs from 0 to 1 inside the warning zone
    function warnMix(frac, warn) {
        if (frac >= warn) {
            return 0;
        }
        const k = 1 - frac / warn;
        return k > 1 ? 1 : k;
    }

Where the Values Change the Difficulty — Amplitude, Tolerance Angle, Threshold

A handful of constants hold the difficulty. The render numbers come first, counted over the 23 changes between 24 captures taken at 480×300. The figures in the subsections under the table come from calling the scene functions on their own in Node, so read them separately from the render columns.

Scene Cumulative area Mean intensity Frames moving
01 Warped glyph board 57.5% 44.3 23/23
02 Dial snap 21.3% 56.5 21/23
03 Tile picker 35.2% 104.2 15/23
04 Join in order 17.9% 111.4 23/23
05 Trace score 12.9% 91.6 23/23
06 Ring check 12.2% 102.6 23/23
07 Noise threshold 75.7% 87.1 23/23
08 Stepped warp 59.7% 79.5 23/23
09 Expiry ring 11.8% 86.4 23/23

Raising WARP_AMP from 3 to 12 moves 56.6% of the stripe pixels

Lay vertical stripes 8 pixels wide over the 240×150 board and call warpPixels at the same phase: 14.9% of the pixels change value at amplitude 3 and 56.6% at amplitude 12. The farthest shift grows fourfold too, from 3 to 12 work pixels, which on the doubled stage is 6px against 24px. The pointer and arrow-key scale runs from 0.1 to 2.5, so the highest amplitude you can reach on this page is 7.5, the WARP_AMP of 3 times 2.5.

A 10° tolerance snaps at exactly 10° and lets 10.5° go

snapCorrection returns −10 for an input of 10°, so it snaps, and null for 10.5°. Because the angle is folded first, 350° lands where −10° does and returns +10, and −8° returns +8. The demo alternates between a release at 24° (SNAP_DEG × 2.4) on even rounds, which fails, and at 5.5° on odd rounds, which snaps.

Widening TOL_PX from 12 to 36 lets a ±24px shaky trail pass

A trail shaken ±14px off the guide (mean distance 8.2px) scores 66 at TOL_PX 12 and 89 at 36. A ±24px trail (mean 13.8px) scores 43 at 12, short of the 60 pass mark, and 81 at 36. The tolerance sits in the formula's denominator, so tripling it cuts the points lost to the same hand shake to a third.

At a threshold of 0.86 only 0.14% of the outside pixels light

Lowering scene 07's threshold wakes the noise outside the letters as well. In the 40,000-sample test, an outside pixel lights with probability 0.14% at 0.86, 8.16% at 0.7, and 25.62% at 0.6. The demo raises the threshold from 0.36 to 0.86 over 72 ticks, holds it for 28 ticks, and lowers it over 22, so one round shows the letters rising out of the noise and sinking back.

TTL_MS 1300 is a value shortened to fit the render

The default of 1300 is a demo value, set short so an expiry shows up once inside a two-second render; a real form would use a longer one. At 5200, the ring's remaining ratio at 1000ms rises from 0.23 to 0.81, and the warning color, which starts at TTL_MS × 0.7, moves from 910ms to 3640ms.

Where It Breaks — Large Canvases, a Paused Tab, Reduced-Motion Settings

When you reuse a captcha css animation with only the values changed, the failures gather in three places: pixel count, judging range, and time. The figures below come from calling source functions directly in Node, and no frame time inside the page was measured.

Raising the work resolution to the canvas size makes the warp four times the work

Scene 01's warpPixels walks 240×150 = 36,000 pixels per tick, running a sine and a clamp for each, and at amplitude 12 one call took 0.55ms in Node on the test machine, which is not an on-screen frame time. Raise the work resolution to 480×300 and the count becomes 144,000 pixels, four times the loop. Keeping the low resolution and doubling it, as now, leaves stair-stepped letter edges, while a large amplitude smears the clamped edge pixels into a single streak. Scene 07 computes at 160×100 and triples it, so its letter edges blur in 3-pixel blocks.

Dragging the dial past the canvas edge clips the coordinate

Scene 02 sets setPointerCapture on pointerdown, so move events keep coming after the pointer leaves the canvas. point() still clips the coordinate to the canvas edge, though, so turning out there does not carry fully into the angle. The spring already overshoots by 10.7% at the current values, and raising SPRING_K while lowering DAMP lengthens the time it spends swinging past the target.

A reference near 0° or 90° hides which tiles have flipped

Scene 03 draws a tile's back with the front stripe angle's sign reversed. Near 0°, the reversed angle is nearly the same, and near 90°, −90° wraps on the 180° stripe period back onto 90°, so front and back look almost alike. Two of the twelve reference angles fall here, and for those two the lighter back color is the only cue.

Seven glyphs leave 37 of 300 seeds short of a full layout

Scene 04 places glyphs in a 392 × 152 area, keeping the 92px minimum gap over up to 400 tries in placeGlyphs. Counting over 300 seeds, no seed comes up short with GLYPH_N up to 5, 5 seeds do at 6, and 37 seeds do at 7. The glyph tremble is a sum of two sines peaking at 8.4px, below the 26px hit radius, so a press during the tremble still lands.

Scene 05's score ignores direction and completion

scoreTrail only averages each sample's distance to the nearest guide point, so tracing the guide backward or stopping halfway still passes when the mean stays small. To use tracing on a real screen, add separate conditions such as reaching the start and end points and moving steadily in one horizontal direction.

Longer timings in scene 06 make the round reset before the last row ends

One row in scene 06 lasts the sum of five constants, 1600ms, and a demo round is 126 ticks (2016ms). The last row starts at tick 17 and finishes at tick 117, leaving only 9 ticks of room, so growing a value like SPIN_MS brings reset() before that row reaches its check mark. Drop MORPH_EASE to 1 and the easing turns linear, and the box no longer seems to swell into a ring.

Scene 08 stops stepping up after the fifth miss

The amplitude cap of 13 is hit on the 4th miss and the line cap of 6 on the 5th, and later misses only raise the count. The five-cell scale on the right looks the same at 5 misses or more, so the scale alone cannot tell five misses from ten; only the count on the status line can. Narrowing the strips from 4px to 1px takes drawImage from 75 calls to 300, and bands as tall as the amplitude show the panel color above and below the board.

Time spent in a hidden tab does not reach scene 09's clock

The shared harness clips each frame gap with Math.min(ts - lastTs, 160). When a hidden tab comes back, the gap counts as 160ms, so scene 09's ring expires later than the real elapsed time. A screen that has to hold an exact expiry should let the server keep the issue time and judge by it, and use this ring only to show the time left.

Reduced-motion settings stop scene 09's time entirely

With reduced motion on, scene 09's ring does not shrink on its own; only the Next scene button and the refresh inputs move it through three states in turn: the start, just before the warning, and the refresh. Applying the same time limit to such a visitor would leave them with no visible countdown, so a live screen needs a separate rule, such as a more generous limit for that visitor.

To find the value where the judging falls apart by changing the amplitude or tolerance angle yourself, you need the source files. The zip password is t8786fwz, and the bundle carries the nine scene sources plus React ports in tsx.

Three Variants and Accessibility

Name Value Feel
Change the warp amplitude 01 WARP_AMP 3 → 12 In the stripe test the changed pixels go from 14.9% to 56.6%, and the largest shift on screen from 6px to 24px.
Change the tolerance 05 TOL_PX 12 → 36 A ±14px shaky trail goes from 66 to 89 points, and a ±24px trail from a failing 43 to a passing 81.
Change the lifetime 09 TTL_MS 1300 → 5200 The remaining ratio at 1000ms rises from 0.23 to 0.81, and the warning color starts at 3640ms instead of 910ms.

These variant numbers also come from calling warpPixels, scoreTrail, ringFrac, and warnMix directly in Node.

A check screen can easily shut out someone who cannot use a pointer. Each of the nine canvases takes Tab focus through tabindex="0" and carries its item name in aria-label, and the canvas and button show a :focus-visible outline. Keys differ by scene: 01 and 07 use arrow keys for amplitude or threshold, 02 turns 5° per ←/→ press and releases the dial 30 ticks after the last press, and 03 moves between tiles with the arrows, picks with Space, and confirms with Enter. 04 selects glyphs with the arrows, 05 lifts and lowers the pen with Space and draws 9px per arrow press, 06 uses ↑/↓ with Enter or Space, 08 returns to the start with Backspace or Escape, and 09 also refreshes on R. preventDefault is attached only to keys a scene actually uses, so Tab still moves on to the next element. The canvas has touch-action: none, so dragging on a phone does not scroll the page along with it (Pointer events).

Status text appears in one <p> with aria-live. While the demo runs alone it is aria-live="off", so a screen reader stays quiet, and once a person takes over it switches to polite, refreshing the text at most once per 44 ticks (about 0.7 seconds).

With prefers-reduced-motion on, the rAF loop does not run. A scene runs REDUCE_TICKS ticks in one go at start and draws one representative frame, and after each input it runs 30 ticks at once and redraws only the result, with no in-between frames. The pause button becomes Next scene, scene 06 alternates between its end and start states when a row is pressed, and in 03 and 04 pressing a solved board opens a new one so input does not get stuck.

Is Captcha Animation CSS Alone Enough?

For the frame alone, HTML and CSS do the job. The stage .cp is width: min(92vw, calc(160vh - 12.8vw)) with aspect-ratio: 480 / 300, a 16px corner radius, and overflow: hidden, and that is the whole of it. The canvas works in 480×300 logical coordinates at a resolution multiplied by devicePixelRatio up to 2, so the scene math stays the same whether the stage shows at about 419×262px in the render capture or about 279×174px on a 320×200 screen. Moving pixels, measuring a noise threshold, and scoring a trail are script work, and pixel reads through getImageData happen once each in 01 and 07, only when the glyph board or letter mask is baked again.

The bigger reason lies elsewhere. Code that runs in the page cannot hide its answer, whether it is HTML, CSS, or JS, so the scenes here serve as the visible face of a check result. Scene 08, which looks harder as misses pile up, pairs with the lock screens in these failed login attempt patterns, the flow of a whole form is covered in login UI design patterns, and a field that shows strength while you type is in the password strength meter set. The timing model follows the requestAnimationFrame reference.

Frequently Asked Questions Before Reusing Captcha CSS Game Code

Does putting one of these scenes in a sign-up form make it a real human check?

No. The judging rule and the answer live in the page script, so anyone who opens the source can read them. Keep issuing the challenge and checking the answer on the server, and use this screen to show the server's challenge and display the result.

Which trails earn 60 points in a scoring captcha animation like scene 05?

At TOL_PX 12, a mean sample distance of 9.6px or less scores 60 or more. A trail shaken ±14px scores 66 and passes, and a ±24px trail drops to 43. Direction and completion are not checked, so the score alone is not enough to judge by.

Does the same seed bring back the same puzzle?

Yes. Random values come only from lcg and hash2, with no Math.random, so in the eight scenes other than 06 one seed decides the glyphs, angles, positions, and guide line. The demo usually raises the seed by 1 at the end of each round to move on to a new puzzle.

Enter the archive password

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