← Blog
Animation

Framer Motion: Animations That Actually Feel Good

By Muzamal Ali8 min readFramer Motion · Animation · UX
Framer Motion: Animations That Actually Feel Good — Animation article by Muzamal Ali

Framer Motion is easy to start with and easy to overuse. This is a working tutorial drawn from production projects: the patterns I actually ship, complete enough to paste into a component, plus the performance rules that keep them at sixty frames per second and the accessibility requirement that is not optional. The difference between animation that feels expensive and animation that feels cheap is almost never the library — it is knowing which properties are safe to move and when to leave the interface still.

Springs versus tweens

Springs feel alive on draggable panels and micro-interactions; tweens feel precise for choreographed page entrances. I default to springs for user-driven motion and short tweens for scroll-linked or sequenced reveals. Mixing both in one component without reason usually reads as inconsistent.

Concretely, my defaults across projects: { type: "spring", stiffness: 300, damping: 30 } for anything a finger or cursor drives, and { duration: 0.4, ease: [0.22, 1, 0.36, 1] } for entrances. Those two configs cover perhaps ninety percent of real work. Durations above 0.5 seconds start to feel like waiting rather than responding, and I have never shipped an entrance longer than 0.6 seconds that a client did not eventually ask to speed up.

How do you animate page transitions in Next.js?

The pattern that works in the App Router keys AnimatePresence on the pathname, so the outgoing route can finish before the incoming one mounts:

"use client";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";

export function PageTransition({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();
  return (
    <AnimatePresence mode="wait" initial={false}>
      <motion.main
        key={pathname}
        initial={{ opacity: 0, y: 12 }}
        animate={{ opacity: 1, y: 0 }}
        exit={{ opacity: 0, y: -8 }}
        transition={{ duration: 0.25, ease: "easeOut" }}
      >
        {children}
      </motion.main>
    </AnimatePresence>
  );
}

Two details matter more than the animation itself. initial={false} suppresses the entrance on first load — animating the landing view delays your largest contentful paint for no user benefit. And mode="wait" prevents both routes existing at once, which otherwise doubles your DOM during the crossfade. Keep the total under 300 milliseconds; navigation should feel confirmed, not performed.

How do you stagger a list without janking?

Manually delaying each child is the common beginner approach and it scales badly. Variants with staggerChildren let the parent own the choreography:

const container = {
  hidden: { opacity: 0 },
  show: {
    opacity: 1,
    transition: { staggerChildren: 0.06, delayChildren: 0.1 },
  },
};

const item = {
  hidden: { opacity: 0, y: 16 },
  show: { opacity: 1, y: 0, transition: { duration: 0.35 } },
};

export function ProjectGrid({ projects }: { projects: Project[] }) {
  return (
    <motion.ul variants={container} initial="hidden" whileInView="show" viewport={{ once: true, amount: 0.2 }}>
      {projects.map((p) => (
        <motion.li key={p.id} variants={item}>
          {p.title}
        </motion.li>
      ))}
    </motion.ul>
  );
}

The stagger interval is where taste lives: 0.05 to 0.08 seconds reads as one coordinated motion, while anything above 0.12 makes users watch items arrive one by one. With twelve cards at 0.06 seconds, the last item lands roughly 1.1 seconds after the first begins — already at the edge of acceptable. Above about fifteen items I cap the stagger or animate only the first row, because nobody waits through a queue.

viewport={{ once: true }} is not optional on long pages. Without it, every scroll past re-runs the animation, which is both distracting and a continuous main-thread cost.

Layout animations

layout and layoutId in Framer Motion solve shared-element transitions when the DOM structure changes — expanding cards, reordering lists. The trick is keeping dimensions stable enough that the browser can interpolate; avoid animating layout on elements whose size depends on async content without a min-height skeleton.

The shared-element case is worth spelling out, because it is the one that genuinely impresses users:

{projects.map((p) => (
  <motion.div key={p.id} layoutId={`card-${p.id}`} onClick={() => setSelected(p.id)}>
    <motion.h3 layoutId={`title-${p.id}`}>{p.title}</motion.h3>
  </motion.div>
))}

<AnimatePresence>
  {selected && (
    <motion.div layoutId={`card-${selected}`} className="modal">
      <motion.h3 layoutId={`title-${selected}`}>{title}</motion.h3>
    </motion.div>
  )}
</AnimatePresence>

Matching layoutId values across two separate trees tell Framer Motion these are the same element, and it interpolates position and size between them. The failure mode to know about: layout animations work by measuring and applying transforms, so they read layout every frame. On a list of fifty items with layout on each, that measurement cost is real. Apply it to the handful of elements that actually move, never to an entire collection by default.

Gestures

Hover and tap states should respect pointer modality. I keep press feedback subtle on desktop and slightly larger on touch. Drag gestures get clear bounds and resistance at edges so users understand limits without reading docs.

<motion.div
  drag="x"
  dragConstraints={{ left: -240, right: 0 }}
  dragElastic={0.15}
  whileTap={{ scale: 0.98 }}
  onDragEnd={(_, info) => {
    if (info.offset.x < -120 || info.velocity.x < -500) onDismiss();
  }}
/>

The onDragEnd logic is the part most tutorials skip: checking velocity alongside distance is what makes a flick feel right. A fast, short swipe should dismiss; a slow drag to the same position should snap back. Users cannot articulate this rule but they notice immediately when it is missing. dragElastic between 0.1 and 0.2 gives enough resistance at the bounds to communicate a limit without feeling broken.

AnimatePresence and exits

Lists and modals need exit animations as much as entrances. Wrapping conditional trees in AnimatePresence with stable key values prevents flashes. I use mode="wait" when swapping mutually exclusive views so outgoing content finishes before incoming mounts.

The bug I have debugged most often here is an unstable key — using an array index, or a value that changes on re-render. Framer Motion then believes the element was replaced rather than persisted, and the exit animation either never plays or plays on the wrong node. If an exit animation is silently not firing, check the key before anything else.

Which properties are safe to animate?

This is the rule that separates smooth from janky, and it is not specific to Framer Motion. The browser renders in stages — layout, paint, composite — and each property you animate re-triggers from its stage down. transform and opacity are composited on the GPU without touching layout or paint. Everything else costs more, and width, height, top, left, and margin are the expensive ones: each frame forces the browser to recalculate the geometry of the page.

Animate thisNot this
x, y (transform)left, top, margin
scalewidth, height
opacitybox-shadow, filter on large areas
rotateborder-width

Framer Motion's x and y props compile to transforms, so using them instead of positional CSS is free correctness. When you genuinely need a size change, layout is the escape hatch — it achieves the visual result with transforms rather than animating the box model directly. Blur filters deserve their own warning: filter: blur() over a large element is recalculated every frame and is one of the most reliable ways to drop a scroll animation from sixty frames to fifteen on mid-range hardware.

How do you respect prefers-reduced-motion?

I read prefers-reduced-motion at the provider level and swap spring configs for short opacity fades or disable non-essential motion entirely. It is a requirement for professional sites, not an enhancement — vestibular disorders make large parallax and sliding motion genuinely unpleasant, not merely unfashionable.

Framer Motion gives you a hook, and the correct response is usually to keep the state change visible while removing the movement:

import { useReducedMotion } from "framer-motion";

export function Reveal({ children }: { children: React.ReactNode }) {
  const reduce = useReducedMotion();
  return (
    <motion.div
      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 24 }}
      whileInView={{ opacity: 1, y: 0 }}
      transition={{ duration: reduce ? 0.15 : 0.4 }}
      viewport={{ once: true }}
    >
      {children}
    </motion.div>
  );
}

Note what this does not do: it does not remove the animation entirely. Content still appears; it simply does not travel. Stripping the transition outright often produces jarring pop-in, which serves nobody.

When should you not animate?

The judgement half of this tutorial. I do not animate:

  • The largest element on the page. An entrance on your hero headline delays the moment the browser records it as painted, and that delay lands directly in your Core Web Vitals. I have measured this costing 1.5 to 2 seconds of reported LCP.
  • Anything above the fold on first load. Users arriving on your page want the content, not a performance.
  • Data that updates frequently. Animating a number that changes every second produces a permanently unsettled interface.
  • Error and validation states. Delaying critical feedback for a 400-millisecond transition is a usability regression wearing a design costume.
  • Anything on a route users visit many times a day. A dashboard entrance animation is delightful on day one and an irritation by day five. Internal tools should be still.

Framer Motion also is not free in bundle terms — the core is roughly 30 to 40 KB gzipped. For a marketing site that is a fine trade. For a single fade on an otherwise static page, a CSS transition costs nothing and does the same job.

What went wrong: the animation that cost two seconds of LCP

The most expensive animation I ever shipped was on my own site. The hero headline animated in character by character — the text was rebuilt into individual spans after hydration, each starting at zero opacity, then staggered upward. It looked excellent on my laptop.

On mobile field data it was a disaster. The headline was the largest contentful element, so the browser recorded LCP not when the server-rendered text first painted but when the JavaScript arrived, rebuilt the DOM, and finished the entrance. Reported LCP sat at 4.7 seconds while the text was visibly on screen within two. PageSpeed mobile scored 64.

The fix was three lines: skip the character animation on touch devices and narrow viewports, keeping the server-rendered text exactly as painted. Mobile LCP dropped to 3.2 seconds and the score moved to 88, with desktop keeping the full effect. What I would do differently is simpler than any code change — check whether an element is your LCP candidate before animating it. That single question would have saved months of a slow hero. The measurement side of this is covered in 5 Next.js optimizations that cut our load time by 60%, and Core Web Vitals in 2026 explains how to spot the same problem in your own field data.

Summary

Animation should reinforce hierarchy and feedback, not decorate for its own sake. Consistent timing, respectful motion settings, and deliberate use of layout animations are what separate polish from noise in production.

The practical checklist: transforms and opacity only, sixty frames verified on a mid-range phone rather than your laptop, reduced motion handled, and nothing animated that the browser measures as your largest element. If you would like this kind of motion work built into a product properly rather than bolted on, that is the sort of engagement described on my services page.

Muzamal Ali

Muzamal Ali — Senior Frontend Engineer & Team Lead

Senior Frontend Engineer with 5+ years building production React and Next.js applications. I've led teams of 3–9 developers across healthcare, aviation, AI, and SaaS platforms. Based in Pakistan, working async with European tech teams.

Working on something similar?

I help European tech teams ship better frontends.

Related Articles