React

React Animation: Framer Motion vs CSS Transitions 2026

Compare React animation approaches in 2026: Framer Motion, CSS transitions, and the View Transitions API — with runtime costs and reduced-motion support explained.

By Laxaar Engineering Team Jul 1, 2026 10 min read
React Animation: Framer Motion vs CSS Transitions 2026

Picking the wrong React animation tool costs more than a few kilobytes of bundle. It costs you layout jank during hydration, broken experiences for users who have prefers-reduced-motion set, and debugging sessions where the animation API fights React's render cycle rather than working with it.

Three approaches dominate React projects right now: plain CSS transitions wired through class toggling or inline styles, Framer Motion with its declarative motion.* components, and the View Transitions API that browsers shipped natively. Each one occupies a different point on the complexity-versus-capability curve. The choice isn't obvious because the demos always look good; the problems surface in production at scale.

We've shipped React animation at Laxaar across everything from marketing pages to data-heavy dashboards, and the honest answer is that all three tools have a home. The mistake is reaching for Framer Motion first without asking whether the dependency is pulling its weight.

What you'll learn

When plain CSS is still the right call

CSS transitions are transforms and opacity changes handled entirely on the compositor thread. No JavaScript runs during the animation. No virtual DOM diffing happens. The browser's rendering engine owns the work from start to finish, which means the main thread stays free for user input and React reconciliation.

For the most common UI animations (hover states, focus rings, toggled visibility, accordion expand/collapse), CSS transitions do the job with zero bundle overhead. You define the property, duration, and easing in a stylesheet or a Tailwind class; React just toggles a class name or a boolean.

// Plain CSS transition via class toggle — zero JS animation cost
function Alert({ visible }: { visible: boolean }) {
  return (
    <div
      className={`alert ${visible ? 'alert--visible' : 'alert--hidden'}`}
      aria-live="polite"
    >
      Changes saved.
    </div>
  );
}
.alert {
  opacity: 0;
  transform: translateY(-4px);
  transition: opacity 200ms ease, transform 200ms ease;
}
.alert--visible {
  opacity: 1;
  transform: translateY(0);
}

The trade-off is real, though. CSS transitions can't animate elements that are being removed from the DOM. Once React unmounts a component, the element is gone and there's no exit animation. That's the gap Framer Motion and the View Transitions API both solve, in different ways.

What Framer Motion actually costs at runtime

Framer Motion is a React animation library that gives you declarative initial, animate, and exit props on motion.* components. It handles enter/exit animations, spring physics, gesture recognition, and layout animations. The API is genuinely good.

The cost is real: Framer Motion adds roughly 43–47 kB gzipped to your bundle (as of v11). On a page that already ships 200 kB of JS, that's a 20+ percent increase for the animation layer alone. That bundle registers an AnimatePresence context, runs a scheduler alongside React's own, and patches the layout system to detect size changes.

For applications with rich interactive animations (draggable cards, shared-element transitions between routes, staggered list reveals), that cost is worth it. For a marketing page that needs one fade-in on scroll, it isn't.

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

// Exit animations require AnimatePresence wrapping the conditional
function Toast({ show, message }: { show: boolean; message: string }) {
  return (
    <AnimatePresence>
      {show && (
        <motion.div
          key="toast"
          initial={{ opacity: 0, y: -8 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -8 }}
          transition={{ duration: 0.2 }}
        >
          {message}
        </motion.div>
      )}
    </AnimatePresence>
  );
}

One underappreciated cost: Framer Motion reads DOM layout synchronously during layout animations, which forces style recalculations. On pages with hundreds of animated nodes, this can produce longer interaction-to-next-paint (INP) times than a CSS-only equivalent.

How layout animations work and where they break

Layout animations are animations that respond to DOM layout changes: a list item reordering, a grid reflowing, an element expanding to reveal more content. CSS transitions can't handle these because the browser doesn't know the element's new position until it's already painted there.

Framer Motion solves this with the layout prop. It reads the element's bounding box before and after a React state change, then uses a CSS transform to play back the delta. The technique is called FLIP (First, Last, Invert, Play) and it's compositor-friendly because only transforms animate.

function SortableList({ items }: { items: string[] }) {
  return (
    <ul>
      {items.map((item) => (
        <motion.li key={item} layout transition={{ type: 'spring', stiffness: 300, damping: 30 }}>
          {item}
        </motion.li>
      ))}
    </ul>
  );
}

Where layout animations break: nested scrollable containers. When an animated element is inside a overflow: scroll parent, Framer Motion measures the bounding box relative to the viewport and can calculate incorrect deltas. You need layoutScroll on the container to fix it. This is documented but easy to miss, and the resulting jank is subtle enough that it slips through review.

The other failure mode is shared layouts across routes. Framer Motion's layoutId can animate an element from one route to another. Impressive in demos, fragile in real apps. It relies on the old element staying mounted long enough for the transition to play, which conflicts with route-level code splitting and React's Suspense boundaries.

The View Transitions API in React apps

The View Transitions API is a browser-native mechanism for animating between two states of the DOM. You call document.startViewTransition(), update the DOM inside the callback, and the browser automatically cross-fades between the old and new states. You can target specific elements with view-transition-name CSS properties to get smooth shared-element transitions.

It's GPU-accelerated, the visual snapshot phase runs outside the main thread, and you need no JavaScript animation library to get it working.

// React integration via a thin wrapper around startViewTransition
function navigateWithTransition(updateFn: () => void) {
  if (!document.startViewTransition) {
    updateFn();
    return;
  }
  document.startViewTransition(updateFn);
}

// Use in a router click handler
function NavLink({ href, label }: { href: string; label: string }) {
  const router = useRouter();
  return (
    <a
      href={href}
      onClick={(e) => {
        e.preventDefault();
        navigateWithTransition(() => router.push(href));
      }}
    >
      {label}
    </a>
  );
}

The catch: browser support is good (Chrome, Edge, Safari 18+) but not universal. Firefox shipped it in late 2025. For apps that need to support older browsers, you need a fallback. The API also doesn't integrate with React's concurrent renderer directly, so using it inside startTransition or with Suspense requires careful sequencing.

React Router v7 and Next.js have experimental View Transitions support that handles the sequencing for you. Worth watching, but not stable enough for production without a feature flag.

Reduced-motion handling

Respecting prefers-reduced-motion isn't optional. It's a WCAG success criterion, and failing it causes real harm to users with vestibular disorders.

Each approach handles it differently:

Plain CSS: add a single media query block that sets transition-duration and animation-duration to near zero for all elements. One rule, global effect.

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

Framer Motion: use the useReducedMotion hook, then pass the result as a condition to your transition prop or to AnimatePresence's mode. You need to do this at every animation call site. That's the real risk: a global CSS override is harder to forget.

import { useReducedMotion } from 'framer-motion';

function FadeIn({ children }: { children: React.ReactNode }) {
  const reduce = useReducedMotion();
  return (
    <motion.div
      initial={{ opacity: reduce ? 1 : 0 }}
      animate={{ opacity: 1 }}
      transition={{ duration: reduce ? 0 : 0.3 }}
    >
      {children}
    </motion.div>
  );
}

View Transitions API: the browser automatically respects prefers-reduced-motion and falls back to a simple cross-fade with no movement. Accessibility is the default, not an opt-in. That makes it the cleanest handling of the three.

Comparison table

CriterionPlain CSSFramer MotionView Transitions API
Bundle cost0 kB~45 kB gzipped0 kB (browser-native)
Exit animationsNo (needs JS)YesYes
Layout animationsNoYes (FLIP)Partial (shared elements)
Spring physicsNoYesNo
Gesture (drag, pan)NoYesNo
Reduced-motion defaultCSS media queryManual per-componentBrowser-enforced
Server renderingSafeSafeSafe (no JS needed)
Browser supportUniversalUniversalChrome/Edge/Safari 18+
React Concurrent safeYesYesNeeds wrapper

The table makes the decision concrete: if you don't need exit animations or layout animations, CSS wins. If you need gesture-driven or physics-based animation, Framer Motion wins. If you're animating between pages or routes and can target modern browsers, the View Transitions API is worth the experiment.

How to pick the right tool

Start with CSS transitions for anything that fits: hover states, focus styles, toggling visibility, and simple entrance effects. Add a prefers-reduced-motion block at the global stylesheet level once and you're done.

Add Framer Motion at the project level only when you hit one of its specific strengths: exit animations, drag interactions, staggered list reveals, or layout animations that CSS can't handle. Don't install it for one fade-in. Our rule at Laxaar is that Framer Motion earns its bundle cost only when three or more animation patterns on a single page need its capabilities.

Treat the View Transitions API as a progressive enhancement. Use it where browser support is adequate for your audience, keep a no-transition fallback, and let the browser handle reduced-motion automatically. It's the future of route-level animation in React apps.

One opinionated take: the React community's default of "install Framer Motion first" has produced a lot of overbuilt animation layers. Most product UIs benefit more from shorter durations and careful easing than from spring physics.

Frequently Asked Questions

Does Framer Motion work with React Server Components?

motion.* components are client components: they use hooks and attach event listeners. You can't render them in a React Server Component directly. Wrap them in a 'use client' boundary, which is the standard pattern for any interactive client-side component in Next.js App Router.

Can we use CSS transitions for exit animations in React?

Not directly. When React removes an element from the DOM, the exit transition has no time to play. You can work around it with refs and manual DOM manipulation, but the result is messy. A lighter alternative to Framer Motion for exit-only use cases is the react-transition-group library, which adds about 6 kB and does one thing well.

How much does the View Transitions API help with Core Web Vitals?

It doesn't directly improve LCP or INP scores, but it prevents the layout shift (CLS) that hard navigations cause when images repaint across routes. The real benefit is perceived performance: the cross-fade makes instant navigations feel smoother without adding JavaScript weight.

Is Framer Motion's layout prop safe to use with large lists?

It's safe for lists up to roughly 50–100 items before the synchronous layout reads start affecting INP. For very long lists, virtualize first (e.g., TanStack Virtual), limit the layout prop to the visible viewport, and test with React DevTools Profiler to confirm the frame budget stays under 50ms.

How do we handle reduced-motion globally in a Framer Motion project?

Create a single MotionConfig provider at the root of the app with reducedMotion="user". This tells Framer Motion to check prefers-reduced-motion automatically and suppress motion for every motion.* component in the tree, with no per-component hook calls needed.

import { MotionConfig } from 'framer-motion';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <MotionConfig reducedMotion="user">
      {children}
    </MotionConfig>
  );
}

If you're building a React product and want the animation layer designed to match your performance budget from day one, the Laxaar team can help. We scope animation choices during architecture, not after the first Lighthouse audit. Reach out at /contact or explore our React and web development work to see how we approach production UI quality.

Working on something like this?

Get a fixed scope, timeline, and price within one business day — no obligation.

Framer MotionCSS TransitionsReact Animation
Grow your business with us

Take your business to the next level.

Tell us what you're building. We'll come back inside one business day with a fixed scope, timeline, and team — or an honest “this isn't a fit”.

ENGINEERING PHILOSOPHY

Code is useless if it's not comprehensible to those who maintain it. We write code the next person can actually understand.