Skip to content

[Bug]: pauseAnimations CSS injection fails to snap opacity-based keyframes to end state #34529

@ryanleecode

Description

@ryanleecode

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

  1. Clone the repo and npm install
  2. Run npx vitest run --project storybook
  3. The Repro/FadeInCard > Default story fails with color-contrast violations
  4. 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).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions