Describe the bug
In vitest browser mode, pauseAnimations() in animation-utils.ts is supposed to snap CSS animations to their end state before applyAfterEach runs (which triggers axe-core color-contrast checks via @storybook/addon-a11y).
The current implementation uses a CSS injection trick: inject animation: none !important, then animation-direction: reverse; animation-play-state: paused, force a reflow via document.body.clientHeight, then remove the first style. For @keyframes that transition opacity: 0 → 1, the browser does not reliably resolve to opacity: 1 after a single synchronous reflow. Elements remain stuck at near-zero opacity, causing axe-core to report foreground colors like #333333 on background #0c0a09 instead of the actual #fafaf9.
This produced 257 false-positive color-contrast violations across 74 tests in our project. The CSS variables resolve correctly (confirmed via getComputedStyle) — the issue is purely the animation compositing state when axe runs.
Replacing pauseAnimations with document.getAnimations() + animation.finish() eliminates all false positives while preserving the synchronous (zero-delay) behavior:
function pauseAnimations(atEnd = true) {
if (!("document" in globalThis && "getAnimations" in globalThis.document))
return () => {};
for (const a of document.getAnimations()) {
if (a.playState === "running") {
try { atEnd ? a.finish() : a.cancel(); } catch (e) {}
}
}
return () => {};
}
Reproduction link
https://github.com/ryanleecode/github-xwwfbuwr
Reproduction steps
- Clone the repo and
npm install
- Run
npx vitest run --project storybook
- The
Repro/FadeInCard > Default story fails with color-contrast violations
- axe reports
foreground color: #333333 — elements at near-zero opacity instead of the expected #fafaf9
The repro uses Tailwind CSS v4 with @theme inline / :root / .dark CSS variable structure, compiled CSS imported in the vitest setup file, dark mode via document.documentElement.classList.add('dark') in beforeAll, and a fade-in keyframe animation (opacity: 0 → 1).
System
Storybook Environment Info:
System:
OS: Linux 6.12 Manjaro Linux
CPU: (12) x64 AMD Ryzen 9 9950X 16-Core Processor
Shell: 5.9 - /bin/zsh
Binaries:
Node: 24.14.0
pnpm: 10.28.2
Browsers:
Firefox: 149.0
npmPackages:
@storybook/addon-a11y: ^10.3.4 => 10.3.4
@storybook/addon-docs: ^10.3.4 => 10.3.4
@storybook/addon-themes: ^10.3.4 => 10.3.4
@storybook/addon-vitest: ^10.3.5 => 10.3.5
@storybook/react: ^10.3.4 => 10.3.4
@storybook/react-vite: ^10.3.4 => 10.3.4
storybook: ^10.3.4 => 10.3.4
Also reproduced on storybook 10.4.0-alpha.8 (same pauseAnimations code).
Additional context
The root cause is in runStory (StoryRender.ts):
isTestEnvironment()
? cleanUp = pauseAnimations() // sync CSS injection — broken
: await waitForAnimations() // async polling — works
The non-test path (waitForAnimations) works correctly because it actually awaits animation.finished promises. The test path chooses CSS injection for speed (zero delay vs 100ms+ polling), but the reflow trick doesn't reliably snap opacity keyframes.
animation.finish() from the Web Animations API is both synchronous and deterministic — it preserves the speed advantage while actually working.
Related: #31877 (same root cause — CSS injection is too blunt for scroll/view-timeline animations), #31900 (PR attempting Web Animations API approach, unmerged).
Describe the bug
In vitest browser mode,
pauseAnimations()inanimation-utils.tsis supposed to snap CSS animations to their end state beforeapplyAfterEachruns (which triggersaxe-corecolor-contrast checks via@storybook/addon-a11y).The current implementation uses a CSS injection trick: inject
animation: none !important, thenanimation-direction: reverse; animation-play-state: paused, force a reflow viadocument.body.clientHeight, then remove the first style. For@keyframesthat transitionopacity: 0 → 1, the browser does not reliably resolve toopacity: 1after a single synchronous reflow. Elements remain stuck at near-zero opacity, causingaxe-coreto report foreground colors like#333333on background#0c0a09instead of the actual#fafaf9.This produced 257 false-positive
color-contrastviolations across 74 tests in our project. The CSS variables resolve correctly (confirmed viagetComputedStyle) — the issue is purely the animation compositing state when axe runs.Replacing
pauseAnimationswithdocument.getAnimations()+animation.finish()eliminates all false positives while preserving the synchronous (zero-delay) behavior:Reproduction link
https://github.com/ryanleecode/github-xwwfbuwr
Reproduction steps
npm installnpx vitest run --project storybookRepro/FadeInCard > Defaultstory fails withcolor-contrastviolationsforeground color: #333333— elements at near-zero opacity instead of the expected#fafaf9The repro uses Tailwind CSS v4 with
@theme inline/:root/.darkCSS variable structure, compiled CSS imported in the vitest setup file, dark mode viadocument.documentElement.classList.add('dark')inbeforeAll, and afade-inkeyframe animation (opacity: 0 → 1).System
Also reproduced on storybook 10.4.0-alpha.8 (same
pauseAnimationscode).Additional context
The root cause is in
runStory(StoryRender.ts):The non-test path (
waitForAnimations) works correctly because it actually awaitsanimation.finishedpromises. The test path chooses CSS injection for speed (zero delay vs 100ms+ polling), but the reflow trick doesn't reliably snap opacity keyframes.animation.finish()from the Web Animations API is both synchronous and deterministic — it preserves the speed advantage while actually working.Related: #31877 (same root cause — CSS injection is too blunt for scroll/view-timeline animations), #31900 (PR attempting Web Animations API approach, unmerged).