EmpireUI
Get Pro
← Blog8 min read#react#animation#framer-motion

React Animation Best Practices: Performance, Accessibility, APIs

React animations can wreck performance or block users with motion sensitivity. Here's how to ship smooth, accessible animations using Framer Motion, CSS, and the Web Animations API.

Abstract colorful motion blur representing web animation and performance

Why Most React Animations Are Slower Than They Should Be

Honestly, most animation bugs aren't about the animation library — they're about animating the wrong CSS properties. You animate width, height, top, left, and then wonder why your app drops to 30fps on a mid-range Android. The browser has to recalculate layout on every frame. That's expensive.

The compositor thread in Chrome and Safari can handle transform and opacity without touching the main thread at all. So instead of animating width: 0px → 300px, you animate scaleX(0) → scaleX(1). Same visual result. Completely different performance profile.

This isn't a minor detail — it's the foundation everything else is built on. Get this wrong and no library, no matter how polished, will save you. Get it right and you can ship sixty-frames-per-second animations with almost no overhead.

Framer Motion v11: What You Actually Need to Know

Framer Motion is the de-facto standard for React animations in 2026, and version 11 cleaned up a lot of the API surface. The motion component is still the core primitive, but the new useAnimate hook and animate function from framer-motion give you full imperative control without needing to wrap every DOM element in a component.

import { motion, useAnimate, stagger } from 'framer-motion';

export function NotificationStack({ items }: { items: string[] }) {
  const [scope, animate] = useAnimate();

  const handleDismissAll = async () => {
    await animate(
      '[data-item]',
      { opacity: 0, x: 60 },
      { duration: 0.2, delay: stagger(0.05) }
    );
  };

  return (
    <div ref={scope}>
      {items.map((item, i) => (
        <motion.div
          key={i}
          data-item
          initial={{ opacity: 0, y: -8 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ type: 'spring', stiffness: 380, damping: 30 }}
          className="mb-2 rounded-lg bg-white/10 px-4 py-3"
        >
          {item}
        </motion.div>
      ))}
      <button onClick={handleDismissAll}>Dismiss all</button>
    </div>
  );
}

That stagger(0.05) adds a 50ms delay between each item's exit animation. It reads naturally. The spring transition with stiffness: 380, damping: 30 gives you the snappy-but-not-jarring feel that Apple UI uses everywhere. These aren't random numbers — tune them against your specific UI weight. Heavier elements need lower stiffness.

CSS Animations vs JavaScript Animations: When to Use Which

There's a persistent myth that CSS animations are always faster than JS animations. That's not quite right. Both CSS and JS animations can run on the compositor thread when you animate transform and opacity. The real difference is control and coordination.

CSS animations are great for simple, stateless transitions — hover states, loading spinners, skeleton pulses. They require zero JavaScript and have zero bundle cost. With Tailwind v4.0.2's animate-* utilities, you can express most of these in a single class. The animate-pulse utility, for example, generates a keyframe that cycles between opacity: 1 and opacity: 0.5 with an ease-in-out timing function over 2 seconds.

JavaScript animations shine when the animation depends on runtime values — drag positions, scroll progress, physics simulations, or sequenced multi-step choreography. If you're building a particle background or responding to user input with spring physics, reach for Framer Motion or the Web Animations API. For a static loading state, just use a CSS class.

The Web Animations API: Native, Zero-Dependency Animations

Most developers skip the Web Animations API (WAAPI) entirely. They shouldn't. It's been well-supported across browsers since 2021, it runs on the compositor thread, and it doesn't cost you a single byte of bundle size. For a component library shipping tree-shakeable components, that matters a lot.

import { useEffect, useRef } from 'react';

export function FadeIn({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const anim = el.animate(
      [
        { opacity: 0, transform: 'translateY(12px)' },
        { opacity: 1, transform: 'translateY(0)' },
      ],
      {
        duration: 240,
        easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
        fill: 'forwards',
      }
    );

    return () => anim.cancel();
  }, []);

  return <div ref={ref}>{children}</div>;
}

That cubic-bezier(0.16, 1, 0.3, 1) is the Expo Out curve — fast initial movement, gentle ease at the end. It feels intentional without being over-the-top. The fill: 'forwards' keeps the element in its final state after the animation completes, which is almost always what you want for entrance animations.

One gotcha: WAAPI doesn't integrate with React's reconciler. If you cancel an animation inside a useEffect cleanup and the component remounts quickly (Strict Mode double-invokes effects in development), you might see flickers. Wrap in window.matchMedia('(prefers-reduced-motion: reduce)').matches checks before running any animation.

Accessibility and prefers-reduced-motion: Not Optional

Vestibular disorders affect roughly 35% of adults over 40. Motion on screen can trigger nausea, dizziness, and headaches in these users. The prefers-reduced-motion media query is how the OS signals that a user has opted into reduced motion. You have to respect it.

In Framer Motion, the useReducedMotion hook gives you a boolean you can use to swap animated variants for instant ones. It's not about removing animation entirely — it's about removing *motion*. Fades are usually fine. Slides, spins, bounces, and parallax effects are not.

import { motion, useReducedMotion } from 'framer-motion';

export function SlideIn({ children }: { children: React.ReactNode }) {
  const prefersReduced = useReducedMotion();

  return (
    <motion.div
      initial={{ opacity: 0, x: prefersReduced ? 0 : -24 }}
      animate={{ opacity: 1, x: 0 }}
      transition={{
        duration: prefersReduced ? 0.01 : 0.3,
        ease: 'easeOut',
      }}
    >
      {children}
    </motion.div>
  );
}

Setting duration: 0.01 instead of 0 is a pragmatic trick — it triggers the animation completion callbacks without any visible motion. Some libraries behave oddly when duration is exactly zero. This approach keeps your sequencing logic intact while effectively eliminating the animation for affected users. If you're also working with toast notifications in React, the same pattern applies to notification enter/exit transitions.

Layout Animations and the Dreaded Layout Thrash

Layout animations — where you animate between two different *sizes or positions* in the DOM — are some of the trickiest to get right. Framer Motion's layoutId prop handles this with a FLIP technique: it snapshots positions before and after a layout change, then animates between them using transform. No layout thrashing, no reflows mid-animation.

The trap developers fall into is mixing layout animations with frequent state updates. If a parent re-renders 60 times per second (say, on scroll), and that parent contains layout children, you're asking the browser to recalculate FLIP snapshots on every frame. Profile with React DevTools Profiler before shipping anything like this.

Is your animation running at 60fps in Chrome but stuttering on Safari iOS? Check whether you've got will-change: transform being set and unset dynamically. Safari is more aggressive about promoting layers, which can cause composite layer explosions on complex pages. Set will-change only on elements actively animating, and remove it when the animation ends. For performance optimization techniques that go beyond animations, that article covers memoization, bundle splitting, and profiling in depth.

Animating Theme Transitions and Dark Mode Swaps

One common request: animate the switch between light and dark mode. The naive approach — wrapping the root in a Framer Motion element and transitioning background colors — runs into a problem. Background color is not a composited property. You're triggering repaints on the entire document.

A better approach is to use a ::before pseudo-element or an overlay div with rgba(255,255,255,0.15) and mix-blend-mode: overlay, fading it in and out on theme switch. Alternatively, the CSS @starting-style rule (available in Chrome 117+ and Firefox 129+) lets you animate properties on elements as they first appear in the DOM, which opens up some interesting patterns for theme swap overlays.

If you're using a theme toggle component in React, you can hook into its onChange callback to trigger a brief overlay fade. Keep the duration under 200ms — theme changes that take longer than that feel sluggish, not polished. The goal is to soften the hard cut, not create a cinematic experience.

Animation Architecture: Where to Put Your Motion Logic

Animation code scattered across components is painful to maintain. A team of four people will end up with four different approaches to the same spring config, four different reduced-motion implementations, and zero consistency in timing.

Consider a central motion.config.ts file that exports named transition presets. Something like springSnappy, springGentle, fadeQuick, slideIn — all with reduced-motion variants baked in. Components import what they need. If a designer wants to adjust the global feel, there's one file to change.

// motion.config.ts
import type { Transition, Variants } from 'framer-motion';

export const spring = {
  snappy: { type: 'spring', stiffness: 400, damping: 32 } as Transition,
  gentle: { type: 'spring', stiffness: 200, damping: 28 } as Transition,
  slow: { type: 'spring', stiffness: 80, damping: 20 } as Transition,
};

export const fadeVariants: Variants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1, transition: spring.gentle },
  exit: { opacity: 0, transition: { duration: 0.15 } },
};

export const slideUpVariants: Variants = {
  hidden: { opacity: 0, y: 16 },
  visible: { opacity: 1, y: 0, transition: spring.snappy },
  exit: { opacity: 0, y: -8, transition: { duration: 0.12 } },
};

This pairs well with a component system like Empire UI where multiple components need consistent motion. Whether it's a modal, a dropdown, or a glassmorphism card effect, pulling from shared presets means your UI breathes as a single coherent system rather than a collection of independent parts doing their own thing.

FAQ

Should I use Framer Motion or the Web Animations API for React projects?

Depends on your constraints. Framer Motion gives you React integration, layout animations, gesture support, and reduced-motion hooks out of the box — worth the ~45kb gzipped for most apps. The Web Animations API is zero-bundle-cost and compositor-threaded, making it ideal for simple entrance/exit animations in a component library where bundle size matters. Many projects use both: WAAPI for micro-interactions, Framer Motion for complex sequenced transitions.

Why does my Framer Motion animation stutter on mobile but run smoothly on desktop?

Most likely you're animating a non-composited property like width, height, padding, or margin. Mobile GPUs don't have the headroom desktop GPUs do. Switch to transform: scale() or transform: translate() equivalents. Also check for layout animations (layout prop) inside frequently re-rendering parents — those trigger expensive FLIP snapshot recalculations on each render.

How do I test that prefers-reduced-motion is working correctly?

In Chrome DevTools, open the Rendering panel (via the three-dot menu → More tools → Rendering) and toggle 'Emulate CSS media feature prefers-reduced-motion'. In Firefox, go to about:config and set ui.prefersReducedMotion to 1. Write a test using @testing-library/react with window.matchMedia mocked to return matches: true. Verify that animated elements either don't move or transition in under 50ms.

What's the right duration for UI animations?

Google's Material Design guidelines suggest 200-300ms for most UI transitions. Apple's HIG uses 250ms as a baseline. In practice: entrance animations around 200-280ms, exit animations 150-200ms (exits should be faster — users don't wait for content to leave), micro-interactions like button feedback 80-120ms. Anything over 400ms needs a very good reason. Longer durations aren't more 'premium' — they're just slower.

Can I use CSS Tailwind animations and Framer Motion together in the same component?

Yes, and it's common. Use Tailwind's animate-* utilities for stateless, loop-based animations like animate-spin, animate-pulse, or animate-bounce. Use Framer Motion for state-driven transitions — enter/exit, hover, drag. Just don't apply conflicting transform values from both simultaneously, or you'll get undefined behavior. One controls transform at a time.

How do I animate a list where items can be added, reordered, or removed?

Use Framer Motion's AnimatePresence wrapper combined with layout prop on each item and a stable key prop. AnimatePresence handles the exit animation before DOM removal. The layout prop triggers FLIP animations when positions change due to reorder or sibling removal. Make sure your keys are stable IDs, not array indices — index-based keys break the FLIP calculation when items move.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Drag-and-Drop in React with dnd kit: Sortable, Multi-ContainerAnimation Performance in React: GPU Layers, will-change and the Right ToolsReact UI Components Complete Reference: 60+ Patterns with CodeParallax Scroll Sections in React: Performance-First Approach