Auditing Animations Against the Three Flashes Threshold
Part of WCAG Animation Conformance in Accessible Motion Architecture.
The problem
Most motion guidance is about comfort. Flashing is about safety. Content that flashes at certain rates, sizes and contrasts can trigger seizures in people with photosensitive epilepsy, and WCAG Success Criterion 2.3.1, Three Flashes or Below Threshold, is a Level A requirement — the minimum for conformance, with no exception for decorative content.
Interface animation rarely sets out to flash, which is why it slips through. A notification badge that blinks, a “live” indicator pulsing between white and red, a skeleton shimmer with high contrast, a celebratory confetti burst, a theme toggle that inverts the page twice quickly, a hero video background — each can cross the threshold without anyone noticing, because the people building and reviewing it are not the people at risk.
Root cause analysis: what counts as a flash
The criterion allows content to flash if it flashes no more than three times in any one-second period, or if the flashes stay below the general flash and red flash thresholds. Passing either condition is enough.
A flash is a pair of opposing changes in relative luminance — bright then dark, or dark then bright — where the change is 10% or more of maximum relative luminance and the darker state is below 0.80 relative luminance. A shimmer between two light greys does not qualify because both states are above 0.80; a pulse from white to mid-grey does.
The general flash threshold is exceeded only when the flashing area is large: combined, more than 25% of any 10-degree visual field on the screen. At typical viewing distances the WCAG understanding documents approximate that as a rectangle of 341 by 256 pixels on a 1024 by 768 display. Anything smaller is below the threshold regardless of rate.
The red flash threshold applies to transitions to or from a saturated red, where the rule is stricter because saturated red is particularly provocative. Any opposing transition involving saturated red in a large enough area counts.
So an animation fails only when all of it is true at once: more than three flashes per second, and a flashing area over the size threshold, with luminance changes big enough or saturated red involved. That makes the audit a measurement problem with four variables — rate, contrast, area and colour — and every fix reduces one of them.
Step-by-step resolution
Automated analysis tools for photosensitive risk exist and are used in broadcast and games; for web animation a small script in the same headless browser used for motion testing is usually enough, because interface effects are short and deterministic.
Production code pattern
// Playwright: sample an effect's frames and count flashes per grid cell.
import { chromium } from 'playwright';
import { PNG } from 'pngjs';
const lum = (r, g, b) => {
const c = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b);
};
async function auditFlashes(url, trigger) {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1024, height: 768 } });
await page.goto(url);
await trigger(page);
const COLS = 16, ROWS = 12, frames = [];
for (let i = 0; i < 60; i++) { // one second at 60 fps
const png = PNG.sync.read(await page.screenshot());
const cells = [];
for (let row = 0; row < ROWS; row++) for (let col = 0; col < COLS; col++) {
const x = Math.floor((col + 0.5) * 1024 / COLS), y = Math.floor((row + 0.5) * 768 / ROWS);
const o = (y * 1024 + x) * 4;
cells.push(lum(png.data[o], png.data[o + 1], png.data[o + 2]));
}
frames.push(cells);
await page.waitForTimeout(16);
}
await browser.close();
const flashingCells = frames[0].map((_, cell) => {
let changes = 0, ref = frames[0][cell], dir = 0;
for (const f of frames) {
const d = f[cell] - ref;
if (Math.abs(d) >= 0.1 && Math.min(f[cell], ref) < 0.8 && Math.sign(d) !== dir) {
changes++; dir = Math.sign(d); ref = f[cell];
}
}
return changes / 2 > 3; // a flash is a pair of opposing changes
});
const cellArea = (1024 / COLS) * (768 / ROWS);
const area = flashingCells.filter(Boolean).length * cellArea;
return { fails: area > 341 * 256, flashingArea: Math.round(area) };
}
/* A "live" indicator redesigned to pass comfortably. */
.live-dot {
inline-size: 0.625rem;
block-size: 0.625rem;
border-radius: 50%;
background: var(--color-live);
animation: live 2s ease-in-out infinite; /* 0.5 flashes per second */
}
@keyframes live {
50% { opacity: 0.55; } /* soft change, tiny area */
}
@media (prefers-reduced-motion: reduce) {
.live-dot { animation: none; } /* the "LIVE" label carries the meaning */
}
Rendering Impact: composite for the redesigned indicator. The audit script is test tooling and does not ship; screenshot timing in a headless browser is approximate, so treat results near the limits as failures and redesign rather than argue the margin.
Screenshot capture is slower than 60 fps on most machines, so the sampling loop above undercounts fast strobes. For effects driven by CSS or the Web Animations API, a more exact approach is to pause every animation with document.getAnimations() and step currentTime in 16.7ms increments between screenshots, which captures precisely the frames the animation defines.
Verification checklist
Constraints and trade-offs
prefers-reduced-motionis not a mitigation for 2.3.1. Users with photosensitive epilepsy may not have the setting enabled, and the criterion applies to everyone.- The area threshold depends on viewing distance and screen size; large displays make the same CSS pixels a smaller visual angle, while phones held close make them larger. Audit conservatively.
- Sampling a grid of points can miss thin flashing shapes; use a finer grid for effects with stripes or text.
- User-generated and third-party content, such as embedded GIFs and ads, falls under the same criterion and cannot be audited at build time.
- Section 2.3.2 Three Flashes at Level AAA removes the threshold exception entirely; if you target AAA, count flashes of any size.
Frequently asked questions
Does prefers-reduced-motion satisfy the three flashes requirement?
No. 2.3.1 applies to all users regardless of their settings, and the people at risk may not have reduced motion enabled. Content must be below the threshold by default.
Is a blinking cursor or a small pulsing dot a problem?
Almost never. Small areas are below the general flash threshold even at high rates, and typical cursor and indicator rates are around one to two changes per second. Large, high-contrast, rapid effects are the risk.
How do I count flashes in a CSS animation precisely?
Pause all animations with document.getAnimations(), step their currentTime in frame-sized increments and capture a screenshot at each step. That records exactly the frames the animation produces without depending on capture speed.
What makes saturated red different?
Transitions involving saturated red are considered more likely to provoke seizures, so WCAG defines a separate red flash threshold. Avoid rapid large-area changes to or from strong red even when luminance changes are modest.
Related
- WCAG Animation Conformance — the parent topic covering 2.2.2, 2.3.1 and 2.3.3
- Providing Pause Controls for Autoplay Motion — the other criterion looping effects must meet
- Writing Playwright Tests for Motion States — the test harness this audit builds on