Animation Quality
Purpose
All animations stay on the compositor thread (60fps), respect user motion preferences, and use the right tool (CSS vs JS) for the job.
Universal — GPU-compositor rules (transform/opacity only), prefers-reduced-motion compliance, CSS-vs-JS choice, and 60fps target are browser-level concerns identical across frameworks.
Procedure
Audit animated properties — restrict to compositor-friendly props
- First, does each animation earn its place? Motion should communicate state change, spatial continuity, or feedback — cut decorative motion that only delays the user.
- Allowed:
transform(translate/scale/rotate/skew),opacity,filter - Banned:
top,left,width,height,margin,padding(trigger layout/paint) - Animating layout also shifts surrounding content → CLS. Entrance/expand animations (accordions,
height: auto) are the classic offender — reserve the space, or animatetransform/grid-template-rows: 0fr→1frinstead ofheight - Search:
grep -rE 'transition.*?(width|height|top|left|margin|padding)' src/
Respect
prefers-reduced-motionglobally- Single source of truth at the app root — opt the whole app into the user's motion preference. (see Implementation; Framer Motion)
- Scope clarification: the global "user-preference" mode disables
transformandlayoutanimations while preserving opacity and color transitions — not all motion is killed. This is the intended behavior (preserves accessibility-safe feedback). - Allow per-component overrides where a critical affordance must still move (e.g., an onboarding cue): detect the preference and conditionally adjust animation props
- Cover decorative animations outside the motion library too (e.g., a CSS-level reduced-motion variant — see Implementation)
- Auto-playing or looping motion that runs >5s alongside other content must be pausable/stoppable — carousels, looping backgrounds, marquees (WCAG 2.2.2)
- Nothing flashes more than 3×/second — avoid rapid flash/strobe effects entirely (WCAG 2.3.1, seizure risk)
- Verify in OS accessibility settings (e.g., macOS System Preferences → Accessibility → Display → Reduce motion)
Audit
will-changeusagewill-changeshould be applied just before animation, removed after- Permanent
will-changeon many elements degrades performance - Use sparingly; default is no
will-change
Choose the right tool — and tune the feel
- CSS transition: simple state changes (hover, focus, open/close)
- Framer Motion / Motion: physics, gestures, complex sequences, layout animations
- Duration: micro-interactions 100–200ms, most UI transitions 200–300ms, larger/page transitions ≤ ~500ms — too-slow motion feels sluggish and hurts perceived performance
- Easing: ease-out for entrances (decelerate in), ease-in for exits, ease-in-out for moves; avoid
linearfor UI motion (reserve it for continuous/looping)
Use the animation library's primitives correctly
- Animate unmount/exit transitions — easy to forget
- Prefer the lib's layout-animation over manual position math for size/position changes
- Ensure shared-element transitions have no orphans (every entry has a matching exit). (see Implementation; Framer
AnimatePresence/layout/layoutId)
Scroll-driven animations
- Prefer
IntersectionObserveror Motion'suseScroll - Avoid
scrollevent listeners (run on main thread)
- Prefer
JS animations use
requestAnimationFrame- Never
setTimeoutfor animation loops - Cancel
rAFon cleanup
- Never
Verify (validation loop)
- Chrome DevTools → Performance: record the interaction; look for dropped frames, layout thrashing, paint regions
- If not 60fps / 0 dropped frames, find the non-compositor property or main-thread work and return to step 1 / step 7
- Confirm interactions stay responsive during animation (INP unaffected — heavy JS animation on the main thread blocks input)
- Toggle OS "Reduce motion" and confirm the app responds; confirm nothing flashes >3×/s
- Loop until: 60fps + 0 dropped frames + reduced-motion honored
Severity tiers
| Tier | Examples | Action SLA |
|---|---|---|
| Critical | Content flashes >3×/second (seizure risk, WCAG 2.3.1); prefers-reduced-motion ignored entirely; auto-playing motion >5s with no pause control (WCAG 2.2.2) |
Block release; fix immediately |
| Major | Animating layout props (top/width/height) causing jank or CLS; dropped frames in the Performance trace; permanent will-change on many elements |
Fix this sprint |
| Minor | Sluggish duration / wrong easing; missing exit (AnimatePresence) animations; scroll listener instead of IntersectionObserver |
Schedule within 2 sprints |
Completion Criteria
- Animated properties limited to
transform/opacity/filter -
prefers-reduced-motionhonored globally - No content flashes >3×/s; auto-playing motion >5s is pausable (WCAG 2.3.1 / 2.2.2)
- No animation shifts surrounding layout (no CLS from motion)
- Dropped frames = 0 in DevTools Performance trace
- No
will-changepermanent declarations -
AnimatePresencecovers all conditional renders with exit animations
Output
- Animation code: refactored to compositor-only properties (transform / opacity / filter)
- Reduced-motion config: global
<MotionConfig reducedMotion="user">(or framework equivalent) wired at app root - Performance trace: Chrome DevTools Performance recording showing 60fps + 0 dropped frames, saved as
docs/perf-trace-YYYY-MM-DD.json - Animation inventory (paste into PR): list of animations with type (decorative / functional / scroll-driven) and library used
- Commit format:
fix(anim): switch <component> to transform/opacityfor fixes;feat(anim): add <interaction>for new
Implementation
React + Next.js (default)
- Library: Motion (formerly Framer Motion) —
<MotionConfig reducedMotion="user">,useReducedMotion()hook,<AnimatePresence>,layout+layoutId - Tailwind reduced-motion variant:
motion-reduce:transition-none - CSS transitions for simple state changes; Motion for physics / gestures / layout
Other stacks
- Vue / Nuxt:
<Transition>/<TransitionGroup>built-in;@vueuse/motionfor spring physics;prefers-reduced-motionvia Tailwind variant or CSS - SvelteKit: built-in
transition:directive (fade,fly,scale,crossfade) +motionpackage;prefers-reduced-motionstore available via@svelte-put/preferences - Angular:
@angular/animationsmodule withtrigger(),state(),animate();BreakpointObserverfor reduced-motion media query - Universal: GPU-compositor rules (transform/opacity only, avoid top/left/width/height) work in every browser;
prefers-reduced-motionis a CSS media query that any framework can detect; Chrome DevTools Performance panel measures dropped frames framework-agnostically
Related skills
rendering-performance— animation jank often manifests as INP regressionaccessibility-audit— reduced-motion respect is a WCAG requirement
Reference
- Key insight encoded: Wrap the app in
<MotionConfig reducedMotion="user">for global opt-out — single source of truth. Constrain animated props totransform/opacityso animations stay on the GPU (and don't shift layout → CLS). Preferlayoutover manual position animation for free FLIP-style transitions. Motion-specific WCAG lives in this skill, not just reduced-motion: nothing flashes >3×/s (2.3.1), and auto-playing motion >5s must be pausable (2.2.2). Tune the feel — fast durations (100–300ms) with ease-out entrances / ease-in exits.