ReactFeatured

Mastering Framer Motion: Production-Grade Animations for React Applications

A deep dive into building polished, performant animations with Framer Motion in React and Next.js — covering layout animations, gesture-driven interactions, scroll-triggered effects, and shared layout transitions.

Sameer Sabir
Updated:
11 min read
Framer MotionReactAnimationNext.jsUI/UXTypeScript

Mastering Framer Motion: Production-Grade Animations for React Applications

Animations are no longer just a nice-to-have — they're a core part of modern user experience. Smooth transitions, micro-interactions, and scroll-driven effects communicate responsiveness and guide users through your interface. Having built animation-heavy production apps like Reflys, I've developed a toolkit of Framer Motion patterns that deliver polish without sacrificing performance.

Why Framer Motion?

Framer Motion is the de facto animation library for React because it:

  • Provides a declarative API that fits naturally into React's component model
  • Handles layout animations automatically
  • Supports gesture recognition (drag, hover, tap, pan)
  • Enables server-side rendering compatibility with Next.js
  • Maintains 60fps through hardware-accelerated transforms

Foundational Patterns

Entrance Animations

The most common pattern — animating elements as they mount:

import { motion } from 'framer-motion';

const fadeInUp = {
  initial: { opacity: 0, y: 30 },
  animate: { opacity: 1, y: 0 },
  transition: { duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] },
};

export const HeroSection = () => (
  <motion.section
    initial={fadeInUp.initial}
    animate={fadeInUp.animate}
    transition={fadeInUp.transition}
    className="hero"
  >
    <h1>Build Something Beautiful</h1>
    <p>Crafted with precision and care.</p>
  </motion.section>
);

Staggered Children

Animate list items with a cascading delay:

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

const itemVariants = {
  hidden: { opacity: 0, y: 20, scale: 0.95 },
  visible: {
    opacity: 1,
    y: 0,
    scale: 1,
    transition: { duration: 0.4, ease: 'easeOut' },
  },
};

export const FeatureGrid: React.FC<{ features: Feature[] }> = ({ features }) => (
  <motion.div
    variants={containerVariants}
    initial="hidden"
    animate="visible"
    className="grid grid-cols-1 md:grid-cols-3 gap-6"
  >
    {features.map((feature) => (
      <motion.div key={feature.id} variants={itemVariants} className="feature-card">
        <feature.icon className="w-8 h-8" />
        <h3>{feature.title}</h3>
        <p>{feature.description}</p>
      </motion.div>
    ))}
  </motion.div>
);

Scroll-Triggered Animations

Using whileInView

The simplest approach for scroll-triggered effects:

export const StatsSection = () => {
  const stats = [
    { label: 'Projects Delivered', value: '50+' },
    { label: 'Happy Clients', value: '30+' },
    { label: 'Years Experience', value: '4+' },
  ];

  return (
    <section className="py-20">
      <div className="grid grid-cols-3 gap-8">
        {stats.map((stat, index) => (
          <motion.div
            key={stat.label}
            initial={{ opacity: 0, scale: 0.8 }}
            whileInView={{ opacity: 1, scale: 1 }}
            viewport={{ once: true, margin: '-100px' }}
            transition={{
              duration: 0.5,
              delay: index * 0.1,
              type: 'spring',
              stiffness: 100,
            }}
            className="text-center"
          >
            <h3 className="text-4xl font-bold">{stat.value}</h3>
            <p className="text-gray-500">{stat.label}</p>
          </motion.div>
        ))}
      </div>
    </section>
  );
};

Scroll-Linked Progress Animations

For effects that track scroll position precisely:

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

export const ParallaxSection = () => {
  const containerRef = useRef<HTMLDivElement>(null);

  const { scrollYProgress } = useScroll({
    target: containerRef,
    offset: ['start end', 'end start'],
  });

  const y = useTransform(scrollYProgress, [0, 1], [100, -100]);
  const opacity = useTransform(scrollYProgress, [0, 0.3, 0.7, 1], [0, 1, 1, 0]);
  const scale = useTransform(scrollYProgress, [0, 0.5, 1], [0.8, 1, 0.8]);

  return (
    <div ref={containerRef} className="relative h-screen overflow-hidden">
      <motion.div style={{ y, opacity, scale }} className="absolute inset-0">
        <img src="/hero-bg.jpg" alt="Parallax background" className="object-cover" />
      </motion.div>
      <div className="relative z-10 flex items-center justify-center h-full">
        <h2 className="text-5xl font-bold text-white">Scroll Magic</h2>
      </div>
    </div>
  );
};

Gesture-Driven Interactions

Draggable Cards

Create Tinder-style swipeable cards:

import { motion, useMotionValue, useTransform, PanInfo } from 'framer-motion';

interface SwipeCardProps {
  card: { id: string; title: string; image: string };
  onSwipe: (direction: 'left' | 'right') => void;
}

export const SwipeCard: React.FC<SwipeCardProps> = ({ card, onSwipe }) => {
  const x = useMotionValue(0);
  const rotate = useTransform(x, [-200, 200], [-25, 25]);
  const opacity = useTransform(x, [-200, -100, 0, 100, 200], [0.5, 1, 1, 1, 0.5]);

  const handleDragEnd = (_: MouseEvent, info: PanInfo) => {
    if (info.offset.x > 100) {
      onSwipe('right');
    } else if (info.offset.x < -100) {
      onSwipe('left');
    }
  };

  return (
    <motion.div
      drag="x"
      dragConstraints={{ left: 0, right: 0 }}
      dragElastic={0.8}
      onDragEnd={handleDragEnd}
      style={{ x, rotate, opacity }}
      whileTap={{ cursor: 'grabbing' }}
      className="absolute w-80 h-96 rounded-2xl bg-white shadow-xl cursor-grab"
    >
      <img src={card.image} alt={card.title} className="rounded-t-2xl h-3/4 object-cover" />
      <h3 className="p-4 text-xl font-semibold">{card.title}</h3>
    </motion.div>
  );
};

Interactive Hover Effects

Subtle hover animations that elevate UI quality:

export const ProjectCard: React.FC<{ project: Project }> = ({ project }) => (
  <motion.article
    whileHover={{ y: -8, boxShadow: '0 20px 40px rgba(0,0,0,0.12)' }}
    whileTap={{ scale: 0.98 }}
    transition={{ type: 'spring', stiffness: 300, damping: 20 }}
    className="rounded-xl overflow-hidden bg-white border border-gray-100"
  >
    <motion.div
      whileHover={{ scale: 1.05 }}
      transition={{ duration: 0.4 }}
      className="overflow-hidden"
    >
      <img src={project.image} alt={project.title} className="w-full h-48 object-cover" />
    </motion.div>
    <div className="p-6">
      <h3 className="text-lg font-bold">{project.title}</h3>
      <p className="text-gray-600 mt-2">{project.description}</p>
      <div className="flex gap-2 mt-4">
        {project.tags.map((tag) => (
          <span key={tag} className="px-3 py-1 bg-gray-100 rounded-full text-sm">
            {tag}
          </span>
        ))}
      </div>
    </div>
  </motion.article>
);

Layout Animations

Shared Layout Transitions

One of Framer Motion's most powerful features — smooth transitions between different layouts:

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

export const ExpandableCard: React.FC<{ items: Item[] }> = ({ items }) => {
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const selectedItem = items.find((item) => item.id === selectedId);

  return (
    <>
      <div className="grid grid-cols-2 gap-4">
        {items.map((item) => (
          <motion.div
            key={item.id}
            layoutId={`card-${item.id}`}
            onClick={() => setSelectedId(item.id)}
            className="p-6 rounded-xl bg-white cursor-pointer"
          >
            <motion.h3 layoutId={`title-${item.id}`}>{item.title}</motion.h3>
            <motion.p layoutId={`desc-${item.id}`}>{item.subtitle}</motion.p>
          </motion.div>
        ))}
      </div>

      <AnimatePresence>
        {selectedItem && (
          <motion.div
            className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={() => setSelectedId(null)}
          >
            <motion.div
              layoutId={`card-${selectedItem.id}`}
              className="w-full max-w-lg p-8 rounded-2xl bg-white"
              onClick={(e) => e.stopPropagation()}
            >
              <motion.h3 layoutId={`title-${selectedItem.id}`} className="text-2xl font-bold">
                {selectedItem.title}
              </motion.h3>
              <motion.p layoutId={`desc-${selectedItem.id}`} className="mt-2 text-gray-600">
                {selectedItem.subtitle}
              </motion.p>
              <motion.div
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                transition={{ delay: 0.2 }}
                className="mt-4"
              >
                <p>{selectedItem.fullDescription}</p>
              </motion.div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </>
  );
};

AnimatePresence for Exit Animations

Handle mount/unmount transitions gracefully:

export const NotificationStack: React.FC<{ notifications: Notification[] }> = ({
  notifications,
}) => (
  <div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
    <AnimatePresence mode="popLayout">
      {notifications.map((notification) => (
        <motion.div
          key={notification.id}
          layout
          initial={{ opacity: 0, x: 100, scale: 0.8 }}
          animate={{ opacity: 1, x: 0, scale: 1 }}
          exit={{ opacity: 0, x: 100, scale: 0.8 }}
          transition={{ type: 'spring', damping: 20, stiffness: 300 }}
          className="p-4 rounded-lg bg-white shadow-lg border min-w-[300px]"
        >
          <p className="font-medium">{notification.title}</p>
          <p className="text-sm text-gray-500">{notification.message}</p>
        </motion.div>
      ))}
    </AnimatePresence>
  </div>
);

Performance Best Practices

1. Animate Transform Properties Only

Stick to x, y, scale, rotate, and opacity — these are GPU-accelerated and won't trigger layout recalculations.

2. Use will-change Sparingly

Framer Motion handles this internally, but for custom CSS animations:

.animate-target {
  will-change: transform, opacity;
}

3. Reduce Motion for Accessibility

Always respect the user's motion preferences:

import { useReducedMotion } from 'framer-motion';

export const AccessibleAnimation: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const prefersReducedMotion = useReducedMotion();

  return (
    <motion.div
      initial={{ opacity: 0, y: prefersReducedMotion ? 0 : 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: prefersReducedMotion ? 0 : 0.5 }}
    >
      {children}
    </motion.div>
  );
};

4. Lazy-Load Animation-Heavy Components

In Next.js, dynamically import Framer Motion components to reduce initial bundle size:

import dynamic from 'next/dynamic';

const AnimatedHero = dynamic(() => import('./AnimatedHero'), {
  ssr: false,
  loading: () => <HeroSkeleton />,
});

Conclusion

Framer Motion transforms React applications from static interfaces into living, breathing experiences. The key is restraint — every animation should serve a purpose, whether it's guiding attention, providing feedback, or creating spatial context.

Key takeaways:

  • Use variants and staggerChildren for orchestrated animations
  • Leverage scroll-linked animations for immersive storytelling
  • Always respect reduced motion preferences
  • Stick to transform and opacity for 60fps performance
  • Use layout animations for seamless state transitions
  • Lazy-load heavy animation components in Next.js

The difference between a good app and a great app often comes down to those subtle micro-interactions that make the interface feel alive.


Found this blog helpful? Have questions or suggestions?

Related Blogs