I was on a call with a team building a sleek new SaaS dashboard. They wanted to add “delightful micro-interactions” — elements that would fade in, slide around, and respond to user input with fluid motion. The lead developer’s question was one I hear constantly: “Should we use pure CSS for performance, or can we pull in a library like Framer Motion without killing our frame rate?”

This is a genuinely important question because the answer has significant implications for both user experience and developer workflow. A blanket “always use CSS” or “always use a library” approach is a failure pattern. The correct choice depends entirely on the specific animation’s complexity. Here is the exact step-by-step framework I use to make this decision, ensuring we ship animations that are both beautiful and genuinely performant.


Step 1: Start with the Browser’s Strengths - CSS Transitions and Animations

Before you even think about installing a new dependency, you must evaluate if the animation can be achieved with CSS. For simple state changes — a modal fading in, a button changing color on hover, a mobile menu sliding out — CSS is almost always the correct and most performant choice.

The reason is simple: modern browsers are incredibly good at optimizing animations of specific CSS properties, namely transform and opacity. When you animate these, the browser can often offload the entire operation to the GPU, leaving the main thread (where your JavaScript runs) completely free. This is the closest you can get to a performance guarantee.

The implementation in React is straightforward. You use state to conditionally apply a CSS class.

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

function Toast({ message }) {
  const [show, setShow] = useState(true);

  // In a real app, you'd have logic to remove this component
  // after the animation finishes.
  
  return (
    <div className={`toast ${show ? 'toast--visible' : ''}`}>
      {message}
    </div>
  );
}

And the corresponding CSS handles the motion.

.toast {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.3s ease-out, transform 0.3s ease-out;
}

.toast--visible {
  opacity: 1;
  transform: translateY(0);
}

This pattern is lightweight, declarative, and leverages what the browser does best. If your animation fits this model, this is your answer. You are done.


Step 2: Evaluate if You Genuinely Need JavaScript’s Power

You’ll quickly find the limits of CSS. It’s not well-suited for animations that need to be dynamic, interactive, or physics-based. This is the point where you should start considering a JavaScript library.

I’ve seen teams try to build complex, interruptible drag-and-drop animations with pure CSS and a mountain of requestAnimationFrame calls. While technically possible, it’s often a direct path to buggy, janky code that is a nightmare to maintain. A dedicated library has already solved these hard problems.

Reach for a JS library like Framer Motion or React Spring when you need:

  • Physics-Based Motion: Creating natural-feeling spring or inertia effects is genuinely difficult with CSS’s cubic-bezier timing functions. Libraries excel at this.
  • Interruptible/Gesture-Driven Animations: If a user needs to drag an element and have it smoothly animate to a final position based on release velocity, a JS library is the right tool.
  • Complex Choreography: Staggering the animation of a list of items or sequencing multiple animations in a timeline is far more declarative and maintainable with a library’s API.

The developer experience is often a huge win. Compare the previous CSS example to a Framer Motion implementation for a staggered list:

import { motion } from 'framer-motion';

const listVariants = {
  visible: {
    transition: {
      staggerChildren: 0.1
    }
  }
};

const itemVariants = {
  hidden: { opacity: 0, y: 20 },
  visible: { opacity: 1, y: 0 }
};

function AnimatedList({ items }) {
  return (
    <motion.ul initial="hidden" animate="visible" variants={listVariants}>
      {items.map(item => (
        <motion.li key={item.id} variants={itemVariants}>
          {item.text}
        </motion.li>
      ))}
    </motion.ul>
  );
}

This kind of sophisticated, maintainable choreography is where libraries provide immense value that justifies their bundle size cost.


Step 3: Profile and Verify, Don’t Assume

So you’ve decided your animation’s complexity justifies a JS library. Your job is not done. The final and most critical step is to profile the result to ensure it actually performs well, especially on lower-end devices. Assuming a library is “fast enough” without measuring is a common source of performance regressions.

Use the Browser’s Performance Monitor in DevTools:

  1. Record a profile while interacting with your animation.
  2. Look at the “Frames” chart. Green bars are good. A red bar indicates a dropped frame, which the user perceives as jank or stutter.
  3. Analyze the “Main” thread waterfall. A pure CSS animation will show almost no activity here. A JS-driven animation will show tasks running on every frame (e.g., “Animation Frame Fired”).
  4. Check the task duration. If these JS tasks are consistently taking more than a few milliseconds, they risk exceeding the ~16.67ms budget for a 60fps frame and could cause jank, especially if other JS is running.

This step is worth stating directly: modern animation libraries are heavily optimized and often use the same hardware-accelerated properties as CSS under the hood. For most use cases, they are perfectly performant. But profiling is the only way to prove it for your specific component on your target devices. It turns your performance decision from a guess into an evidence-backed engineering choice.


Quick Decision-Making Reference

Criteria Best Fit: CSS Best Fit: JavaScript Library
Performance Highest possible, offloaded to GPU. Excellent, but runs on the main thread. Requires verification.
Complexity Simple A-to-B state transitions. Physics, gestures, complex sequencing.
Bundle Size Zero cost. A non-trivial addition (e.g., Framer Motion is ~25kB gzipped).
Developer Exp. Can be cumbersome for complex needs. Highly declarative and powerful for complex animations.

The Right Tool for the Right Job

Ultimately, we advised the team to use CSS transitions for their simple hovers and modal fades, and to adopt Framer Motion specifically for a complex, gesture-driven card stack feature. By following this step-by-step process, they didn’t have to engage in a dogmatic debate. They made targeted, evidence-based decisions for each component. The result was an application that felt alive and responsive, without adding unnecessary library weight or sacrificing performance where it mattered most. Animation performance isn’t about choosing one technology over the other; it’s about understanding the trade-offs and building a toolkit of both.

What’s the most complex animation you’ve had to build in a React app, and which approach did you take? I’m curious to hear about the challenges you faced.