flash-guard WCAG 2.3.1 flash analysis and a live governor

Count the flashes WCAG counts, and stop them while they happen.

Pairs of opposing luminance changes of 10% or more, in any 10-degree visual field, more than three times in any second, plus the PEAT/Harding red-flash criterion. The offline half audits frames you already have. The live half watches a canvas, counts the same flashes as they appear, and solves for the brightness that puts the next one under the limit. Pure TypeScript, no dependencies.

npm install flash-guard Check a video
A full-field strobe at 20 flashes per second in yellow, and the same strobe as the viewer sees it through the governor in blue, clamped within a few frames and held at 3 flashes per second
A real run of the governor against a synthetic strobe, generated by bun run figure:svg. Flash rates in the caption are scored by the same analysis the video checker below uses.

These are screen-content heuristics, not a medical device. They flag content worth review and reduce measured flash rate; they do not certify anything as safe.

Check a video

Frames are decoded and analysed in your browser with analyzeRgbaFrames. Nothing is uploaded. Sampling is 30 frames a second at 320 pixels wide, which is what the standard's area and rate tests need.

Pick a file, or try the synthetic strobe: two seconds of calm motion, then a quarter-screen strobe.

Live governor

The canvas below strobes at the rate and contrast you choose. With the governor on, createFlashController samples the canvas inside each draw, counts flashes with the same rule, and dims the canvas through a CSS brightness filter until the swing no longer qualifies.

This panel can strobe. Keep the contrast low or the governor on if flashing affects you. It starts at low contrast.

flashes in window
0
hold
0.000
luminance scale applied
1.000
engaged
no
sampler
-
luminance drawnafter the governor's filter

The rule, as implemented

WCAG 2.3.1 saysflash-guard does
A flash is a pair of opposing changes in relative luminance of 10% or more of maximumA tile transition qualifies when the swing is at least 0.1; a flash is counted when a qualifying field-wide transition reverses the previous one
...where the darker image is below 0.80The darker end of the pair must be under 0.8
The flashing area must exceed 25% of any 10-degree visual fieldA window a third of each axis slides across the tile grid with a summed-area table; the transition counts if any window has 25% qualifying pixels
More than three flashes in any one-second period failsPeak count over any sliding 1 s window, compared against 3
Red flash: a transition to or from a saturated redSaturated when R/(R+G+B) is at least 0.8; qualifies when max(0, R-G-B)ยท320 changes by more than 20

Magnitude is decided per pixel before any spatial aggregation. Averaging luminance over a tile first scales every swing down by the flashing region's coverage, and on sparse bright-on-black content that pushed real flashes two orders of magnitude under the threshold.

How the governor decides

It engages before the limit

Intervention starts at the second flash in the window, not the fourth. Acting only on the fourth would mean the failing sequence had already been shown.

It solves rather than ramps

On a flash it measures the swing it just observed and computes the scale that puts that swing under the threshold, then jumps there in one step. Blind ramping let a black-to-white strobe land nine flashes before the clamp caught up.

It starts gentle

The first hold is 0.2, so a mild flicker is not punished like a full strobe; the solved step finds the right level.

It releases slowly, after a delay

Release begins once the window has been clear for 1.5 s and eases off at 0.004 per frame. Releasing as soon as the window emptied let the strobe restart and settle into a limit cycle at 8 flashes a second.

It does not release into a running strobe

While the content, undimmed, still exceeds the limit, release stops where its recent transitions sit at 80% of the threshold. Easing off until flashes landed again let a red strobe just over the limit reach 6 flashes in a second.

It compares frames 16.7 ms apart

On a 120 Hz display, frame-to-frame twinkle in calm content read as 82 flashes a second; compared one 60 Hz frame apart it read 2.4, and real strobes read the same either way.

It reads what the viewer sees

The sample is scaled by the mitigation already in force. Otherwise the governor never observes its own effect and escalates to the ceiling.

Cost of the live sample

The sampler snapshots the canvas inside the draw with createImageBitmap, already downscaled to a 16x16 grid, and hands it to a worker whose readback does the waiting for the GPU. Measured on an M1 Max at 1280x720 in headless Chromium, per sample:

BackendMain thread, synchronous readMain thread, off-thread readGrid arrives after
WebGPU1.9 to 2.3 ms0.1 ms2.1 to 2.3 ms
WebGL2.7 to 3.6 ms0.1 ms2.8 to 3.2 ms

API at a glance

analyzeRgbaFrames(frames, options)RGBA frames in, FlashAnalysis out. createFlashFrameCounter is the streaming form.
analyzeFlashTimeline({ frames, deltaMs, cols, rows })Per-frame luminance grids in, analysis out.
analyzeFlashEvents(input)Per-tile qualifying-pixel counts in, analysis out; the offline path that evaluates red flash.
classifyFlashRisk(analysis)none, low, medium or high; high means over the WCAG limit on either channel.
createFlashGovernor(options)The live state machine: one luminance grid per frame in, and optionally its red-flash field, { hold, luminanceScale, flashesInWindow, redFlashesInWindow } out.
createFlashController({ canvas, applyLuminanceScale })Sampler plus governor plus the apply-on-change loop; call tick(now) from your draw.
createBrightnessFilterApplier(element)An applier that writes a CSS brightness() filter.
createFlashSampler({ grid, createWorker })Canvas to luminance and red-flash fields, off the main thread when possible.
relativeLuminance, peakWindowFraction, the threshold constantsThe primitives every path is built from, defined once.

The full API, the measured design notes and the provenance of every file are in the README.