Skip to main content
Back to Research

Production Animation Systems with Framer Motion

LazyMotion for code splitting, AnimatePresence patterns, motion values for performant effects, and respecting prefers-reduced-motion without gutting your…

Abstract

Framer Motion is powerful but easy to misuse in production. This article covers the patterns that keep animations performant: LazyMotion bundle splitting, correct AnimatePresence usage, motion values for GPU-composited effects, prefers-reduced-motion integration, and diagnosing hydration mismatches caused by animation state.

February 1, 2026
7 min read

The Bundle Problem

The default Framer Motion import is expensive. import { motion } from 'framer-motion' pulls in the full library — layout animations, 3D transforms, SVG animation support, gesture recognizers, and all animation primitives. The gzipped cost is approximately 50KB before any application code runs.

For a site where animations are non-critical rendering — below-the-fold, progressive enhancement — this is wasteful. The LazyMotion API exists precisely to solve this:

// Define which features to load — domAnimation covers most use cases
const loadFeatures = () => import('framer-motion').then(r => r.domAnimation);

// Wrap your component tree
import { LazyMotion, m } from 'framer-motion';

export function AnimatedSection({ children }: { children: React.ReactNode }) {
  return (
    <LazyMotion features={loadFeatures} strict>
      {children}
    </LazyMotion>
  );
}

// Inside the tree, use m.div instead of motion.div
export function FadeIn({ children }: { children: React.ReactNode }) {
  return (
    <m.div
      initial={{ opacity: 0, y: 16 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.4, ease: 'easeOut' }}
    >
      {children}
    </m.div>
  );
}

The strict prop throws an error during development if any code in the tree imports from motion (the full bundle) instead of m. Enable it. It catches the leak early.

domAnimation supports basic animations, gestures, and exit animations. Use domMax if you need layout animations (the layout prop on motion elements). Layout animations are significantly heavier to compute — think twice before using them on large DOM trees.

AnimatePresence: The Right Way

AnimatePresence is the mechanism for animating components as they exit the DOM. The most common pattern — animating route transitions or conditional UI — has several non-obvious constraints.

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

function TabPanel({ activeTab }: { activeTab: string }) {
  return (
    <AnimatePresence mode="wait">
      <m.div
        key={activeTab} // REQUIRED: key change triggers exit/enter cycle
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
        transition={{ duration: 0.2 }}
      >
        {renderTab(activeTab)}
      </m.div>
    </AnimatePresence>
  );
}

The key prop is mandatory. AnimatePresence tracks components by key — when a key changes, the old component exits and the new one enters. Without a changing key, AnimatePresence thinks the same component is being updated, not replaced, and no exit animation fires.

mode="wait" sequences the exit before the enter. This prevents both the old and new content from being visible simultaneously. For most UI transitions, this is the correct behavior. mode="sync" (the default) runs exit and enter concurrently — use it when you want overlap, like a cross-fade.

AnimatePresence must be a direct parent of the components it manages. You cannot wrap it around multiple independent animated elements and expect them to coordinate — wrap it around each independently.

Motion Values for Performant Effects

For scroll-driven animations, resist the urge to put animation logic in useEffect with a scroll event listener. This runs on the main thread, causes layout thrashing, and produces janky animations on lower-end devices.

Motion values are Framer Motion's reactive primitives. Combined with useScroll and useTransform, they drive animations entirely through the animation system without triggering React re-renders:

import { useScroll, useTransform, m } from 'framer-motion';
import { useRef } from 'react';

export function ParallaxHero() {
  const ref = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ['start start', 'end start'],
  });

  // Transform scroll progress (0-1) to a y offset
  const y = useTransform(scrollYProgress, [0, 1], ['0%', '30%']);
  const opacity = useTransform(scrollYProgress, [0, 0.8], [1, 0]);

  return (
    <div ref={ref} className="relative h-screen overflow-hidden">
      <m.div style={{ y, opacity }} className="absolute inset-0">
        {/* background content */}
      </m.div>
    </div>
  );
}

The key: style={{ y, opacity }} with motion values. Framer Motion updates these via the Web Animations API or direct style mutation — whichever is faster for the property — without re-rendering the component. The browser can compositor-thread the animation if the only properties being animated are transform and opacity. Do not animate width, height, top, or left with motion values if you care about jank-free performance.

Respecting prefers-reduced-motion

Vestibular disorders affect a meaningful percentage of users. Animations that translate, rotate, or scale elements can trigger nausea and vertigo. prefers-reduced-motion: reduce is a system-level signal that users set in their OS accessibility preferences. Ignoring it is an accessibility failure.

Framer Motion provides useReducedMotion:

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

export function FadeIn({ children }: { children: React.ReactNode }) {
  const shouldReduceMotion = useReducedMotion();

  return (
    <m.div
      initial={shouldReduceMotion ? false : { opacity: 0, y: 16 }}
      animate={{ opacity: 1, y: 0 }}
      transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.4 }}
    >
      {children}
    </m.div>
  );
}

When shouldReduceMotion is true: disable transforms (keep opacity fades — they're generally safe), reduce or eliminate transition durations, and avoid looping animations entirely.

A cleaner pattern is a wrapper component that handles this globally:

export function SafeMotion({ children, className, ...animationProps }: SafeMotionProps) {
  const shouldReduceMotion = useReducedMotion();

  return (
    <m.div
      {...animationProps}
      initial={shouldReduceMotion ? { opacity: 0 } : animationProps.initial}
      animate={shouldReduceMotion ? { opacity: 1 } : animationProps.animate}
      exit={shouldReduceMotion ? { opacity: 0 } : animationProps.exit}
      transition={shouldReduceMotion ? { duration: 0.15 } : animationProps.transition}
      className={className}
    >
      {children}
    </m.div>
  );
}

Use SafeMotion as a drop-in replacement for m.div wherever motion comfort matters. This is not optional — it's table stakes for an accessible production site.

Hydration Mismatches from Animation State

Framer Motion components that animate on mount (initial={{ opacity: 0 }} animate={{ opacity: 1 }}) can cause hydration warnings. The server renders the component at its final state; the client renders it at its initial state; React detects a mismatch.

The fix: wrap animated components in 'use client' boundaries so they're never rendered server-side at all, or use the initial={false} prop on AnimatePresence to suppress mount animations on first render:

<AnimatePresence initial={false}>
  {isVisible && <m.div exit={{ opacity: 0 }}>content</m.div>}
</AnimatePresence>

With initial={false}, child components enter without their initial animation on first render. Subsequent shows will animate normally. This matches the server-rendered state and eliminates the mismatch.

Performance Profiling Animations

Chrome DevTools' Performance tab has a "Rendering" layer that shows paint and compositor activity. When profiling animations:

  • Green bars in the compositor thread indicate GPU-composited animations — these are cheap
  • Orange bars in the main thread during animation indicate layout or paint triggered by the animation — these are expensive
  • Any frame that exceeds 16ms (60fps) or 8ms (120fps) on a high-refresh display will produce visible jank

The properties that trigger layout: width, height, top, left, margin, padding, font-size. The properties that stay on the compositor thread: transform (translate, scale, rotate), opacity, filter (with caveats). Design animations around this list. If a design requires animating width or height, use scaleX/scaleY transforms instead — they look similar and stay on the compositor.

Continue Reading
JCJOOTACEE / OPS

Operational laboratory for AI systems, automation infrastructures, and modular digital ecosystems.

Systems

  • AURA Orchestration
  • MCP Ecosystem
  • Graph Memory
  • AI Agents
  • Docker Infrastructure
  • Industrial Intelligence

System Status

PlatformOperational
APIHealthy
3D EngineActive
MCP Nodes8 Online

Try the Konami code...

Stay in the loop

Occasional updates on AI systems, autonomous infrastructure, and new releases.

© 2026 JootaCee. All systems operational.

RSSChangelogNext.js 16 + React 19 + R3F + GSAP