IAMUVIN

UI/UX & Design Engineering

Framer Motion in Next.js: Animation Patterns That Don't Kill Performance

Uvin Vindula·March 17, 2025·11 min read

Last updated: April 14, 2026

Share

TL;DR

Framer Motion is the best animation library for React — but it's also the easiest way to tank your Interaction to Next Paint and Cumulative Layout Shift scores if you use it wrong. I use Framer Motion on every project I build (iamuvin.com, EuroParts Lanka, uvin.lk, Heavenly Events), but I follow a strict rule: CSS transitions handle the simple stuff — hover states, color changes, opacity fades. Framer Motion only comes in when I need layout animations, orchestrated sequences, exit animations, or scroll-driven reveals that need intersection observer logic. This article walks through every animation pattern I use in production, the Reveal component I copy into every project, and the performance guardrails that keep my sites scoring 95+ on Lighthouse.


When to Use Framer Motion vs CSS

This is the decision I make before writing a single line of animation code. Getting it wrong means either shipping a bloated bundle for simple transitions or writing painful imperative animation logic that Framer Motion handles in three lines.

Use CSS transitions when:

  • You're animating opacity, transform, background-color, or box-shadow
  • The animation triggers on hover, focus, or a simple state toggle
  • There's no layout change involved
  • You don't need exit animations
css
/* This doesn't need Framer Motion */
.card {
  transition: transform 200ms ease-out, box-shadow 200ms ease-out;
}

.card:hover {
  transform: translateY(-4px);
  box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
}

Use Framer Motion when:

  • You need AnimatePresence for exit animations (elements leaving the DOM)
  • You're doing layout animations where elements reflow
  • You need orchestrated sequences — staggered children, chained animations
  • You need scroll-triggered animations with fine-grained control
  • You're building page transitions in Next.js

Here's my mental model: if the animation involves the element *appearing*, *disappearing*, or *changing position relative to other elements*, Framer Motion earns its bundle cost. If it's just a visual property shifting on hover, CSS wins every time.

On iamuvin.com, roughly 70% of animations are pure CSS. The remaining 30% — scroll reveals, page transitions, the project card layout animations — those are Framer Motion. That ratio keeps the bundle lean while still delivering the motion design I want.


Scroll Reveal Pattern

This is the pattern I use the most. Every section on iamuvin.com, every service card on my services page, every testimonial — they all fade in as you scroll down. The trick is doing this without causing layout shift or janky scroll performance.

Here's the wrong way to do it:

tsx
// DON'T DO THIS — causes CLS because the element starts at 0 height
<motion.div
  initial={{ opacity: 0, height: 0 }}
  whileInView={{ opacity: 1, height: 'auto' }}
>
  <ServiceCard />
</motion.div>

Animating height causes layout shift. Every element below it jumps. Google penalizes this in CLS scores. Instead, animate only opacity and transform — these are compositor-only properties that run on the GPU without triggering layout recalculation.

tsx
'use client';

import { motion } from 'framer-motion';

const scrollRevealVariants = {
  hidden: {
    opacity: 0,
    y: 24,
  },
  visible: {
    opacity: 1,
    y: 0,
    transition: {
      duration: 0.6,
      ease: [0.16, 1, 0.3, 1], // ease-out-expo
    },
  },
};

function ServiceCard({ title, description }: ServiceCardProps) {
  return (
    <motion.div
      variants={scrollRevealVariants}
      initial="hidden"
      whileInView="visible"
      viewport={{ once: true, margin: '-80px' }}
      className="rounded-2xl border border-white/5 bg-[#111827] p-8"
    >
      <h3 className="text-xl font-semibold text-white">{title}</h3>
      <p className="mt-3 text-[#C9D1E0]">{description}</p>
    </motion.div>
  );
}

Two things to notice. First, viewport={{ once: true }} — the animation plays once and never re-triggers. Re-triggering scroll animations on every scroll up/down is obnoxious and wastes CPU cycles. Second, margin: '-80px' — this triggers the animation 80px before the element enters the viewport, so it feels like it's already in motion when the user sees it. Without this offset, the user catches the element at opacity 0 for a split second, which looks like a flash.

The ease: [0.16, 1, 0.3, 1] cubic bezier is the IAMUVIN signature easing. It's an exponential ease-out — fast start, gentle deceleration. It feels natural and deliberate, not bouncy or mechanical.


Page Transitions

Page transitions in Next.js are where Framer Motion truly earns its keep. The App Router's layout system makes this surprisingly clean, but there are pitfalls that will ruin your performance if you're not careful.

The key component is AnimatePresence. It lets you animate elements *out* before they're removed from the DOM — something CSS cannot do.

tsx
'use client';

import { AnimatePresence, motion } from 'framer-motion';
import { usePathname } from 'next/navigation';

const pageTransitionVariants = {
  initial: {
    opacity: 0,
    y: 12,
  },
  animate: {
    opacity: 1,
    y: 0,
    transition: {
      duration: 0.4,
      ease: [0.16, 1, 0.3, 1],
    },
  },
  exit: {
    opacity: 0,
    y: -8,
    transition: {
      duration: 0.25,
      ease: [0.4, 0, 1, 1], // ease-in for exits
    },
  },
};

export function PageTransition({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();

  return (
    <AnimatePresence mode="wait">
      <motion.div
        key={pathname}
        variants={pageTransitionVariants}
        initial="initial"
        animate="animate"
        exit="exit"
      >
        {children}
      </motion.div>
    </AnimatePresence>
  );
}

Place this in your root layout, wrapping the {children} slot:

tsx
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Navbar />
        <PageTransition>{children}</PageTransition>
        <Footer />
      </body>
    </html>
  );
}

Performance warning: mode="wait" means the exit animation must complete before the enter animation starts. This adds latency to every navigation. On iamuvin.com I keep exit durations at 250ms max. Anything longer and the site feels sluggish. If your pages load data with Suspense, the transition can clash with streaming — test this thoroughly.

There's a subtlety with mode="wait" that bites people: during the exit animation, the old page's content is still mounted. If that content has running effects or intervals, they'll keep executing. Clean up your effects.

For iamuvin.com, I actually use mode="popLayout" on some transitions instead — it allows the new page to mount immediately while the old one animates out. It's faster but requires careful z-index management:

tsx
<AnimatePresence mode="popLayout">
  <motion.div
    key={pathname}
    initial={{ opacity: 0, scale: 0.98 }}
    animate={{ opacity: 1, scale: 1 }}
    exit={{ opacity: 0, scale: 0.98 }}
    transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
    style={{ position: 'relative', zIndex: 1 }}
  >
    {children}
  </motion.div>
</AnimatePresence>

Layout Animations

Layout animations are what sold me on Framer Motion years ago. When an element changes size or position due to a state change — a filter being applied, an accordion opening, a tab switching — Framer Motion can animate the transition smoothly using FLIP (First, Last, Invert, Play) under the hood.

tsx
'use client';

import { motion, LayoutGroup } from 'framer-motion';
import { useState } from 'react';

const categories = ['All', 'Web3', 'AI', 'Design'] as const;

function ProjectFilter() {
  const [active, setActive] = useState<string>('All');

  return (
    <LayoutGroup>
      <div className="flex gap-2">
        {categories.map((category) => (
          <button
            key={category}
            onClick={() => setActive(category)}
            className="relative rounded-full px-4 py-2 text-sm text-[#C9D1E0]"
          >
            {active === category && (
              <motion.div
                layoutId="activeFilter"
                className="absolute inset-0 rounded-full bg-[#F7931A]"
                transition={{
                  type: 'spring',
                  stiffness: 380,
                  damping: 30,
                }}
              />
            )}
            <span className="relative z-10">{category}</span>
          </button>
        ))}
      </div>
    </LayoutGroup>
  );
}

The layoutId prop is doing the magic here. When the active category changes, the orange background element doesn't just appear in the new position — it *moves* there. Framer Motion calculates the position delta and creates a smooth animation. No absolute positioning math. No GSAP timelines. Three lines of configuration.

I use this pattern for:

  • Tab indicators that slide between tabs
  • Active navigation indicators
  • Filter chips on portfolio pages
  • Card selections in multi-step forms

The performance trap: layout animations trigger getComputedStyle and getBoundingClientRect calls. If you put layout on 50 elements in a list, you'll get 50 forced layout recalculations per frame during animation. Limit layout to the elements that actually move. Use layoutId for shared elements, and layout for elements whose own dimensions change. Never put layout on a parent container that wraps hundreds of children.


Exit Animations

This is the feature that makes Framer Motion irreplaceable. In React, when you conditionally render a component — {isOpen && <Modal />} — the component is removed from the DOM instantly. There's no opportunity to animate it out. CSS transition can't help because the element is already gone.

AnimatePresence solves this by keeping the element in the DOM during the exit animation, then removing it when the animation completes:

tsx
'use client';

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

function NotificationToast({ message, isVisible, onClose }: ToastProps) {
  return (
    <AnimatePresence>
      {isVisible && (
        <motion.div
          initial={{ opacity: 0, y: 50, scale: 0.95 }}
          animate={{ opacity: 1, y: 0, scale: 1 }}
          exit={{ opacity: 0, y: 20, scale: 0.95 }}
          transition={{
            duration: 0.35,
            ease: [0.16, 1, 0.3, 1],
          }}
          className="fixed bottom-6 right-6 z-50 rounded-xl border border-white/10 bg-[#1A2236] px-6 py-4 shadow-2xl"
        >
          <p className="text-sm text-white">{message}</p>
          <button
            onClick={onClose}
            className="mt-2 text-xs text-[#F7931A] hover:underline"
          >
            Dismiss
          </button>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

The key requirement: AnimatePresence must be the *direct parent* of the conditional element. You can't nest it three components up and expect it to catch removals deep in the tree. I've seen people wrap their entire app in a single AnimatePresence — that doesn't work and causes confusing behavior.

I use exit animations for:

  • Toast notifications (slide down and fade)
  • Modal dialogs (scale down and fade)
  • Dropdown menus (slide up with opacity)
  • Deleted list items (collapse and fade)
  • Route transitions (as shown in the page transitions section)

Staggered Children

Staggered animations — where child elements animate in sequence with a small delay between each — create a sense of intentionality. Instead of everything appearing at once, each element enters with a slight offset, creating a wave effect.

tsx
'use client';

import { motion } from 'framer-motion';

const containerVariants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.08,
      delayChildren: 0.1,
    },
  },
};

const itemVariants = {
  hidden: {
    opacity: 0,
    y: 20,
  },
  visible: {
    opacity: 1,
    y: 0,
    transition: {
      duration: 0.5,
      ease: [0.16, 1, 0.3, 1],
    },
  },
};

function ProjectGrid({ projects }: { projects: Project[] }) {
  return (
    <motion.div
      variants={containerVariants}
      initial="hidden"
      whileInView="visible"
      viewport={{ once: true, margin: '-60px' }}
      className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"
    >
      {projects.map((project) => (
        <motion.div
          key={project.id}
          variants={itemVariants}
          className="group rounded-2xl border border-white/5 bg-[#111827] p-6 transition-colors hover:border-[#F7931A]/20"
        >
          <h3 className="text-lg font-semibold text-white">
            {project.title}
          </h3>
          <p className="mt-2 text-sm text-[#C9D1E0]">
            {project.description}
          </p>
        </motion.div>
      ))}
    </motion.div>
  );
}

The staggerChildren: 0.08 means each child starts its animation 80ms after the previous one. At 80ms, a grid of 6 items takes 480ms to fully reveal — perceivable but not slow. I've tested different values extensively:

  • 0.04s — feels simultaneous, barely noticeable stagger
  • 0.08s — the sweet spot for grids and lists
  • 0.12s — works for 3-4 items, too slow for longer lists
  • 0.2s+ — dramatic, only use for hero sections with 2-3 elements

The variant propagation is what makes this clean. The parent containerVariants controls the stagger timing. The children inherit hidden and visible states automatically — you don't need to pass initial and animate to each child. As long as the child has matching variant names, Framer Motion handles the orchestration.


Performance — Avoiding Layout Thrash

Animation performance is where most developers get into trouble with Framer Motion. The library is well-optimized, but it can't protect you from bad patterns. Here's what I've learned from profiling animations on iamuvin.com with Chrome DevTools.

Rule 1: Only animate compositor-friendly properties.

The browser renders in three phases: Layout, Paint, Composite. Properties that trigger layout (width, height, padding, margin, top, left) are the most expensive. Properties that trigger only compositing (opacity, transform) are essentially free — they're handled by the GPU.

tsx
// EXPENSIVE — triggers layout on every frame
<motion.div animate={{ width: isOpen ? 300 : 0 }} />

// CHEAP — GPU composited, no layout recalculation
<motion.div animate={{ scaleX: isOpen ? 1 : 0 }} />

Rule 2: Use `will-change` sparingly.

Framer Motion adds will-change: transform automatically to animated elements. This tells the browser to promote the element to its own GPU layer. That's good for the animation, but too many promoted layers eat VRAM. If you have 100 animated cards on a page, you have 100 GPU layers. On mobile devices, this causes jank or crashes.

My approach: animate on scroll reveal with viewport={{ once: true }}. Once the animation is done, the element is static. Framer Motion cleans up the will-change automatically after the animation completes.

Rule 3: Avoid animating during scroll handlers.

tsx
// DON'T DO THIS — fires on every scroll event
const { scrollY } = useScroll();
const opacity = useTransform(scrollY, [0, 300], [1, 0]);

return (
  <motion.div style={{ opacity }}>
    <HeavyComponent />
  </motion.div>
);

This is fine for a single hero section. But if you have 10 elements with scroll-linked animations, each one is recalculating transforms on every scroll frame. Use useMotionValueEvent instead of useTransform when you need to trigger state changes, and debounce where possible.

Rule 4: Measure with the Performance tab.

Open Chrome DevTools > Performance > check "Screenshots" > record a scroll through your page. Look for:

  • Long frames (red bars in the frame timeline) — animation is too expensive
  • Layout thrashing (purple bars in the Main thread) — you're animating layout properties
  • High GPU memory (in the Layers panel) — too many composited layers

On iamuvin.com, I profile every page after adding animations. The target is zero frames above 16.6ms during any animation. If I see jank, I either simplify the animation, reduce the number of animated elements, or switch back to CSS transitions.


Server Components Compatibility

This is the biggest gotcha when using Framer Motion with Next.js App Router. Server Components are the default in Next.js, and Framer Motion is a client-side library. Every component that uses motion.div needs the 'use client' directive.

The mistake I see constantly: wrapping an entire page in 'use client' just to animate a section header. This defeats the purpose of Server Components — you've just moved everything to the client, increasing bundle size and losing streaming.

My pattern: create thin client wrapper components for animation, keep the content server-rendered.

tsx
// components/reveal.tsx — Client Component (animation wrapper)
'use client';

import { motion, type Variants } from 'framer-motion';
import { type ReactNode } from 'react';

interface RevealProps {
  children: ReactNode;
  className?: string;
  delay?: number;
}

const variants: Variants = {
  hidden: { opacity: 0, y: 24 },
  visible: { opacity: 1, y: 0 },
};

export function Reveal({ children, className, delay = 0 }: RevealProps) {
  return (
    <motion.div
      variants={variants}
      initial="hidden"
      whileInView="visible"
      viewport={{ once: true, margin: '-80px' }}
      transition={{
        duration: 0.6,
        delay,
        ease: [0.16, 1, 0.3, 1],
      }}
      className={className}
    >
      {children}
    </motion.div>
  );
}
tsx
// app/services/page.tsx — Server Component (data fetching, no 'use client')
import { Reveal } from '@/components/reveal';
import { getServices } from '@/lib/services';

export default async function ServicesPage() {
  const services = await getServices();

  return (
    <section className="py-24">
      <Reveal>
        <h2 className="text-4xl font-bold text-white">Services</h2>
      </Reveal>

      <div className="mt-12 grid gap-8 md:grid-cols-2 lg:grid-cols-3">
        {services.map((service, index) => (
          <Reveal key={service.id} delay={index * 0.08}>
            <ServiceCard service={service} />
          </Reveal>
        ))}
      </div>
    </section>
  );
}

The page itself remains a Server Component. It can fetch data, use async/await, stream with Suspense. The Reveal wrapper is a thin client boundary that only adds animation behavior. The ServiceCard inside it can be a Server Component too — the 'use client' boundary doesn't force children to be client components, it just means the Reveal component itself runs on the client.

This pattern keeps my client-side JavaScript minimal. The Framer Motion code is only a few KB per animation wrapper, and the content itself is server-rendered HTML.


My Animation Library

Over the last two years, I've settled on a small set of reusable animation variants that I copy into every project. They live in a single file — lib/animations.ts — and cover 90% of my animation needs.

tsx
// lib/animations.ts
import { type Variants } from 'framer-motion';

// Exponential ease-out — the IAMUVIN signature
export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const;
export const EASE_IN = [0.4, 0, 1, 1] as const;

export const fadeIn: Variants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: { duration: 0.5, ease: EASE_OUT_EXPO },
  },
};

export const fadeInUp: Variants = {
  hidden: { opacity: 0, y: 24 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.6, ease: EASE_OUT_EXPO },
  },
};

export const fadeInDown: Variants = {
  hidden: { opacity: 0, y: -16 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.5, ease: EASE_OUT_EXPO },
  },
};

export const fadeInLeft: Variants = {
  hidden: { opacity: 0, x: -24 },
  visible: {
    opacity: 1,
    x: 0,
    transition: { duration: 0.6, ease: EASE_OUT_EXPO },
  },
};

export const fadeInRight: Variants = {
  hidden: { opacity: 0, x: 24 },
  visible: {
    opacity: 1,
    x: 0,
    transition: { duration: 0.6, ease: EASE_OUT_EXPO },
  },
};

export const scaleIn: Variants = {
  hidden: { opacity: 0, scale: 0.92 },
  visible: {
    opacity: 1,
    scale: 1,
    transition: { duration: 0.5, ease: EASE_OUT_EXPO },
  },
};

export const staggerContainer: Variants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.08,
      delayChildren: 0.1,
    },
  },
};

export const staggerItem: Variants = {
  hidden: { opacity: 0, y: 20 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.5, ease: EASE_OUT_EXPO },
  },
};

Every variant uses the same easing curve. Consistency in easing is what separates professional motion design from "I added some animations." When every element on your site moves with the same physical character — the same acceleration, the same deceleration curve — the whole experience feels coherent.

I never use Framer Motion's spring type for scroll reveals. Springs are great for interactive gestures (drag, tap) where the physics feel matters, but for entrance animations, a well-tuned cubic bezier is more predictable and performs better.


The Reveal Component I Use Everywhere

This is the final, production version of the Reveal component from iamuvin.com. It handles every scroll reveal case I've encountered and stays under 40 lines.

tsx
'use client';

import { motion, type Variants } from 'framer-motion';
import { type ReactNode } from 'react';

type Direction = 'up' | 'down' | 'left' | 'right';

interface RevealProps {
  children: ReactNode;
  className?: string;
  direction?: Direction;
  delay?: number;
  duration?: number;
  once?: boolean;
}

const EASE_OUT_EXPO = [0.16, 1, 0.3, 1];

function getOffset(direction: Direction) {
  const distance = 24;
  const map: Record<Direction, { x?: number; y?: number }> = {
    up: { y: distance },
    down: { y: -distance },
    left: { x: -distance },
    right: { x: distance },
  };
  return map[direction];
}

export function Reveal({
  children,
  className,
  direction = 'up',
  delay = 0,
  duration = 0.6,
  once = true,
}: RevealProps) {
  const offset = getOffset(direction);

  return (
    <motion.div
      initial={{ opacity: 0, ...offset }}
      whileInView={{ opacity: 1, x: 0, y: 0 }}
      viewport={{ once, margin: '-80px' }}
      transition={{ duration, delay, ease: EASE_OUT_EXPO }}
      className={className}
    >
      {children}
    </motion.div>
  );
}

Usage is dead simple:

tsx
<Reveal>
  <h2>Default — fades up</h2>
</Reveal>

<Reveal direction="left" delay={0.1}>
  <p>Slides in from the left with a 100ms delay</p>
</Reveal>

<Reveal direction="right" delay={0.2} duration={0.8}>
  <StatCard value="150+" label="Projects Delivered" />
</Reveal>

Why this works as a production component:

  1. It's a Client Component boundary — the children inside can still be Server Components
  2. `once: true` by default — animation plays once, then the component is static (no ongoing performance cost)
  3. Only animates `opacity` and `transform` — GPU-composited, no layout thrash
  4. The `margin: '-80px'` viewport — animations start before the element is fully in view, preventing visible pop-in
  5. Configurable but opinionated — you can change direction and timing, but the easing and distance are locked to maintain consistency

I've shipped this component in six different projects. It's never caused a CLS issue, never dropped a frame on mobile, and it takes less than 2KB in the client bundle. It's the kind of small, focused utility that earns its keep on every page.


Key Takeaways

  • CSS first, Framer Motion second. If a CSS transition can do the job, use it. Framer Motion is for layout animations, exit animations, and orchestrated sequences. Not hover states.
  • Only animate `opacity` and `transform`. These are compositor-only properties handled by the GPU. Animating width, height, top, or left triggers layout recalculation and kills performance.
  • Use `viewport={{ once: true }}` on scroll reveals. Replay animations waste CPU and annoy users. Play once, then get out of the way.
  • Keep exit animations under 250ms. Longer exit animations make navigation feel sluggish. Fast in, faster out.
  • Create thin client wrappers for animations. Don't slap 'use client' on entire pages. Build small animation components like Reveal and keep your page-level components as Server Components.
  • Stagger at 80ms intervals. Fast enough to feel like a wave, slow enough for each element to be individually perceived. Adjust down for long lists, up for hero sections.
  • Profile every animated page. Chrome DevTools Performance tab is your reality check. If you see red frames during your animation, simplify until you don't.
  • Centralize your variants. One lib/animations.ts file with consistent easing curves across the project. Inconsistent motion feels broken even when it works correctly.

Animation is a design tool, not decoration. Every motion on iamuvin.com exists to communicate something — hierarchy, state change, spatial relationship. When animation is purposeful, users don't notice it. They just feel that the site is *polished*. That's the goal.


*Written by Uvin Vindula — Web3 and AI engineer building production-grade applications from Sri Lanka and the UK. I work with clients worldwide on Next.js, smart contracts, and AI integrations. See what I can build for you at iamuvin.com/services.*

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.