React Transitions and Animations: A Practical, High‑Performance Guide

A practical guide to smooth animations in React using CSS, libraries, and useTransition—covering patterns, performance, accessibility, and testing.

ASOasis
8 min read
React Transitions and Animations: A Practical, High‑Performance Guide

Image used for representation purposes only.

Overview

Smooth, meaningful motion can turn a good React app into a great one. This guide walks through the practical ways to build transitions and animations in React—ranging from CSS transitions to full-featured libraries—while explaining how React’s concurrent features like useTransition and startTransition help keep animations responsive during heavy state updates.

What you’ll learn:

  • When to use CSS, the Web Animations API, or a library
  • Mount/unmount transitions and route/page transitions
  • Coordinating animations with React state using useTransition
  • Performance, accessibility, and testing best practices

The core options at a glance

  • CSS transitions/animations: Small, declarative, zero‑dependency. Great for simple hover, fade, and slide effects.
  • Web Animations API (WAAPI): Imperative, keyframe‑driven animations in JavaScript with good performance and timing control.
  • Libraries:
    • React Transition Group (RTG): Minimal, “bring your own CSS,” focused on mount/unmount and list transitions.
    • Framer Motion: High-level, batteries-included, expressive API with layout and exit animations.
    • react-spring: Physics‑based springs for natural motion; excels at dynamic, data‑driven transitions.
  • React concurrent transitions: useTransition/startTransition help avoid UI stalls while animations run alongside expensive renders.

A simple rule of thumb:

  • Prefer CSS for simple effects.
  • Use Framer Motion or RTG for page/route and list enter/exit.
  • Reach for react-spring for physics and continuous, data‑driven motion.
  • Consider WAAPI when you need low-level control or to avoid extra deps.

CSS transitions: the baseline

CSS remains the lightest way to animate in React—toggle classes or inline styles from state.

import { useState } from 'react';
import './panel.css';

export default function SlidingPanel() {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(o => !o)}>{open ? 'Close' : 'Open'} Panel</button>
      <div className={`panel ${open ? 'panel--open' : ''}`}>Hello</div>
    </div>
  );
}
/* panel.css */
.panel {
  transform: translateY(-8px);
  opacity: 0;
  transition: transform 200ms ease, opacity 200ms ease;
}
.panel--open {
  transform: translateY(0);
  opacity: 1;
}

Pros: tiny, fast, simple. Cons: coordinating enter/exit on unmount is tricky without helpers.

Mount/unmount with React Transition Group

React Transition Group gives lifecycle hooks for when elements enter and leave the DOM.

npm i react-transition-group
import { CSSTransition, TransitionGroup } from 'react-transition-group';

function TodoList({ items, onRemove }) {
  return (
    <TransitionGroup component="ul" className="todos">
      {items.map(item => (
        <CSSTransition key={item.id} classNames="fade" timeout={200}>
          <li>
            {item.text}
            <button onClick={() => onRemove(item.id)}></button>
          </li>
        </CSSTransition>
      ))}
    </TransitionGroup>
  );
}
.fade-enter { opacity: 0; transform: translateY(-4px); }
.fade-enter-active { opacity: 1; transform: translateY(0); transition: all 200ms ease; }
.fade-exit { opacity: 1; }
.fade-exit-active { opacity: 0; transform: translateY(-4px); transition: all 150ms ease; }

Why it’s great: It delays unmount until the exit animation finishes, and it scales to lists.

Framer Motion: expressive, layout‑aware animations

Framer Motion provides declarative props and exit animations via AnimatePresence.

npm i framer-motion
import { motion, AnimatePresence } from 'framer-motion';

export default function Modal({ open, onClose }) {
  return (
    <AnimatePresence>
      {open && (
        <motion.div
          className="backdrop"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
        >
          <motion.div
            className="modal"
            initial={{ y: 24, opacity: 0 }}
            animate={{ y: 0, opacity: 1 }}
            exit={{ y: 24, opacity: 0 }}
            transition={{ type: 'spring', stiffness: 500, damping: 35 }}
          >
            <button onClick={onClose}>Close</button>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

Highlights:

  • AnimatePresence enables exit animations before unmount.
  • layout and layoutId create FLIP-style layout transitions across components.
  • Variants coordinate complex, multi-element sequences.

Physics with react-spring

react-spring models motion with springs—ideal for natural-feel UI and list transitions.

npm i react-spring
import { useTransition, animated } from '@react-spring/web';

function Chips({ items }) {
  const transitions = useTransition(items, {
    keys: item => item.id,
    from: { opacity: 0, transform: 'scale(0.9)' },
    enter: { opacity: 1, transform: 'scale(1)' },
    leave: { opacity: 0, transform: 'scale(0.9)' },
    trail: 40
  });
  return transitions((style, item) => (
    <animated.span style={style} className="chip">{item.label}</animated.span>
  ));
}

Web Animations API (no library needed)

WAAPI is imperative and great for fine-grained control and timelines.

import { useEffect, useRef } from 'react';

function Pulse() {
  const ref = useRef(null);
  useEffect(() => {
    const el = ref.current;
    const anim = el.animate(
      [ { transform: 'scale(1)' }, { transform: 'scale(1.06)' }, { transform: 'scale(1)' } ],
      { duration: 600, easing: 'ease-in-out', iterations: Infinity }
    );
    return () => anim.cancel();
  }, []);
  return <button ref={ref} className="cta">Buy now</button>;
}

Pros: great performance, cancelable, timeline control. Cons: more code, less declarative.

Keeping animations smooth with React’s useTransition

Animations can stutter when state updates trigger expensive renders. React’s concurrent features help prioritize what’s urgent.

  • useTransition: Mark certain updates as “transition” work. React will keep urgent interactions (like typing) responsive while deferring non-urgent updates.
  • startTransition: Imperative API to wrap non-urgent setState calls.
import { useState, useTransition, startTransition } from 'react';

function Search({ records }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState(records);
  const [isPending, start] = useTransition();

  function onChange(e) {
    const q = e.target.value;
    setQuery(q); // urgent: reflect keystroke immediately

    start(() => {
      // non-urgent: expensive filtering and list diffing
      const next = records.filter(r => r.name.includes(q));
      setResults(next);
    });
  }

  return (
    <div>
      <input value={query} onChange={onChange} placeholder="Filter" />
      {isPending && <span className="dim">Updating</span>}
      <LargeAnimatedList items={results} />
    </div>
  );
}

Guidelines:

  • Wrap only the expensive, non-urgent updates.
  • Pair with a visual pending state for feedback.
  • Works well with animated lists (Framer Motion/RTG) to keep 60fps feel while data recalculates.

Layout transitions (FLIP) and measurement

When elements change size/position, aim for transform/opacity rather than layout-affecting properties.

FLIP technique:

  1. First: read current position
  2. Last: render to final position
  3. Invert: apply transform to counter the delta
  4. Play: animate transform back to zero

Framer Motion’s layout/layoutId automates this. For custom implementations, measure in useLayoutEffect and use transforms.

import { useLayoutEffect, useRef, useState } from 'react';

function FlipCard({ expanded }) {
  const ref = useRef(null);
  useLayoutEffect(() => {
    const el = ref.current;
    const first = el.getBoundingClientRect();
    // render to final state synchronously
    // (in React 18+, you can force a sync flush if needed)
  }, [expanded]);
  return <div ref={ref} className={expanded ? 'card card--open' : 'card'} />;
}

Tip: If precise measurement is required immediately after a state change, use useLayoutEffect to avoid visible jumps.

Route and page transitions

For React Router, wrap routes in AnimatePresence to animate navigation and exits.

import { Routes, Route, useLocation } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';

function AppRoutes() {
  const location = useLocation();
  return (
    <AnimatePresence mode="wait">
      <Routes location={location} key={location.pathname}>
        <Route path="/" element={<Page>Home</Page>} />
        <Route path="/about" element={<Page>About</Page>} />
      </Routes>
    </AnimatePresence>
  );
}

function Page({ children }) {
  return (
    <motion.main
      initial={{ opacity: 0, y: 12 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: -12 }}
      transition={{ duration: 0.2 }}
    >
      {children}
    </motion.main>
  );
}

Accessibility: motion that respects users

  • honors prefers-reduced-motion: reduce or disable animations for users who opt out.
  • Avoid parallax/auto-scrolling for vestibular safety.
  • Keep focus management intact during route transitions (return focus to meaningful targets).
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

In component libraries (e.g., modals), consider a noMotion prop to disable motion programmatically when needed.

Performance checklist

  • Animate transform and opacity; avoid layout-triggering properties (top/left/width/height) when possible.
  • Prefer GPU-friendly 2D transforms; be conservative with will-change to avoid memory bloat.
  • Batch state updates and use memoization for large lists.
  • Virtualize long lists; don’t try to animate thousands of DOM nodes at once.
  • Keep images and fonts loaded before animating; use content-visibility and lazy loading.
  • Measure: use Chrome DevTools Performance panel and FPS meter; profile both JS and rendering.

Coordinating data fetching and animation

When a transition also loads data, show immediate lightweight motion (skeletal shimmer or scale-in) while deferring heavy list renders via useTransition. This keeps the UI responsive and avoids jank.

function Products() {
  const [isPending, start] = useTransition();
  const [category, setCategory] = useState('all');
  const [items, setItems] = useState([]);

  async function changeCategory(next) {
    setCategory(next); // urgent: reflect selection
    start(async () => {
      const data = await fetch(`/api/products?cat=${next}`).then(r => r.json());
      setItems(data);
    });
  }

  return (
    <div>
      <Tabs value={category} onChange={changeCategory} />
      <GridSkeleton visible={isPending} />
      <AnimatedGrid items={items} />
    </div>
  );
}

Testing animations

  • Prefer asserting end states rather than timing. Use findBy* queries to wait for DOM after animation completes.
  • In unit tests, reduce durations to 0 or mock animation frames.
  • For accessibility, include snapshots with prefers-reduced-motion enabled to ensure no blocking animations remain.
// Example with Jest + Testing Library
jest.mock('framer-motion', () => ({
  ...jest.requireActual('framer-motion'),
  motion: (c) => c, // shallow out motion wrappers for logic tests
}));

Common pitfalls and fixes

  • Unmounting before exit animation finishes: use RTG or AnimatePresence.
  • Jank during expensive renders: wrap non-urgent work in useTransition/startTransition.
  • Measuring layout after render causing flicker: move to useLayoutEffect; avoid reading layout mid-animation.
  • Animating large images/SVG filters: pre-rasterize if possible, simplify SVGs, or avoid animating filters.
  • Conflicting CSS and JS animations: keep a single source of truth per property.

Choosing the right tool: a quick decision guide

  • “I only need simple fades/slides.” → CSS transitions.
  • “I want reliable enter/exit and list item transitions with my own CSS.” → React Transition Group.
  • “I want expressive, production‑ready motion with layout/exit and variants.” → Framer Motion.
  • “I need physics‑based, data‑driven motion.” → react-spring.
  • “I need low-level timeline control without a dependency.” → Web Animations API.
  • “My animations stutter when data updates.” → Combine your choice above with useTransition.

Final thoughts

React doesn’t prescribe a single animation solution—and that’s a strength. Start simple with CSS, adopt a library where it compounds your velocity and quality, and use React’s concurrent transitions to keep everything responsive. With the right tool for the job and a focus on accessibility and performance, your UI can feel fast, fluid, and polished.

Related Posts