Command Palette

Search for a command to run...

React UI Animation: Principles, Patterns, and Production Recipes (2025)

React UI Animation: Principles, Patterns, and Production Recipes (2025)

A practical guide to motion in React/Next.js—what to animate, how to animate it, and how to keep it accessible, fast, and maintainable with Framer Motion and CSS.

8 min read·Animation

Motion should clarify intent and reduce cognitive load, not decorate. This guide condenses proven principles, patterns, and production recipes for React + Next.js using CSS and Framer Motion—with accessibility and performance built in.

Related reading:
Web Design Best Practices · Web Application Design · UI Libraries


Table of Contents

  1. Why motion (and when not to use it)
  2. Durations, easings, and motion tokens
  3. CSS first: fast, simple wins
  4. Framer Motion: variants, presence, gestures
  5. Route transitions in the App Router
  6. Micro-interactions that matter
  7. Reduced motion & accessibility
  8. Performance guardrails
  9. Testing motion (unit, a11y, e2e)
  10. Patterns catalog (copy-ready)
  11. Decision matrix: CSS vs Framer Motion vs others
  12. FAQ

Why motion (and when not to use it)

Use motion to explain state changes (open/close, success/error), guide focus, and express hierarchy (parent → child reveal). Avoid motion that blocks tasks, loops without purpose, or triggers vestibular discomfort.

  • Animate entrances/exits of overlays, toasts, drawers.
  • Convey continuity with directional transitions (from the source).
  • Prefer small distances and low amplitude; subtle beats show confidence.

Durations, easings, and motion tokens

Create motion tokens you can reference in CSS/JS so timing is consistent across surfaces.

/* tokens.css */
:root {
  --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
  --ease-in: cubic-bezier(0.4, 0, 1, 1);
  --ease-spring: cubic-bezier(0.25, 1, 0.5, 1);
  --dur-1: 150ms; /* micro: hover, tap */
  --dur-2: 220ms; /* UI elements */
  --dur-3: 320ms; /* overlays */
}
// motion.ts
export const motionTokens = {
  durations: { s: 0.15, m: 0.22, l: 0.32 },
  easings: {
    out: [0.2, 0.8, 0.2, 1] as [number, number, number, number],
    in: [0.4, 0.0, 1, 1] as [number, number, number, number],
  },
};

CSS first: fast, simple wins

CSS handles hover, focus, and small reveals with near‑zero JS.

/* buttons.css */
.btn {
  transform: translateY(0);
  transition:
    transform var(--dur-1) var(--ease-out),
    box-shadow var(--dur-1) var(--ease-out);
}
.btn:hover {
  transform: translateY(-1px);
}
.btn:active {
  transform: translateY(1px);
}
// app/components/Accordion.tsx (details/summary baseline)
export function Accordion({
  title,
  children,
}: {
  title: string;
  children: React.ReactNode;
}) {
  return (
    <details className="group rounded-lg border p-3 open:shadow-sm">
      <summary className="cursor-pointer select-none font-medium outline-none transition-colors group-open:text-foreground">
        {title}
      </summary>
      <div
        className="mt-2 overflow-hidden will-change-transform
        data-[open=true]:animate-in data-[open=false]:animate-out"
      >
        {children}
      </div>
    </details>
  );
}

Tip: Use transform/opacity only; avoid animating layout properties (width/height/left/top).


Framer Motion: variants, presence, gestures

Install:

pnpm add framer-motion

Variants (scale across states)

"use client";
import { motion } from "framer-motion";
 
const card = {
  initial: { opacity: 0, y: 8 },
  enter: { opacity: 1, y: 0, transition: { duration: 0.22 } },
  exit: { opacity: 0, y: 8, transition: { duration: 0.18 } },
};
 
export function FadeCard({ children }: { children: React.ReactNode }) {
  return (
    <motion.div variants={card} initial="initial" animate="enter" exit="exit">
      {children}
    </motion.div>
  );
}

AnimatePresence (manage mount/unmount)

"use client";
import { AnimatePresence, motion } from "framer-motion";
 
export function Toasts({
  items,
}: {
  items: Array<{ id: string; text: string }>;
}) {
  return (
    <div className="fixed bottom-4 right-4 space-y-2">
      <AnimatePresence initial={false}>
        {items.map((t) => (
          <motion.div
            key={t.id}
            initial={{ opacity: 0, y: 8 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 8 }}
            transition={{ duration: 0.22 }}
            className="rounded-md bg-black/90 px-3 py-2 text-white shadow-lg"
          >
            {t.text}
          </motion.div>
        ))}
      </AnimatePresence>
    </div>
  );
}

Gestures (tap/drag)

"use client";
import { motion } from "framer-motion";
 
export function TapButton({ children }: { children: React.ReactNode }) {
  return (
    <motion.button
      whileTap={{ scale: 0.96 }}
      whileHover={{ y: -1 }}
      transition={{ type: "spring", stiffness: 480, damping: 42 }}
      className="rounded-md bg-neutral-900 px-4 py-2 text-white"
    >
      {children}
    </motion.button>
  );
}

Route transitions in the App Router

Use a client wrapper and key by the pathname.

"use client";
import { motion, AnimatePresence } from "framer-motion";
import { usePathname } from "next/navigation";
 
export function RouteTransition({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();
  return (
    <AnimatePresence mode="wait">
      <motion.div
        key={pathname}
        initial={{ opacity: 0, y: 8 }}
        animate={{ opacity: 1, y: 0 }}
        exit={{ opacity: 0, y: 8 }}
        transition={{ duration: 0.22, ease: [0.2, 0.8, 0.2, 1] }}
      >
        {children}
      </motion.div>
    </AnimatePresence>
  );
}

For route‑bound modals, consider intercepting routes and parallel routes so dialogs have URLs and respect Back/Forward.


Micro-interactions that matter

  • Inputs: subtle focus ring grow/shrink (box-shadow change), debounce highlight on valid.
  • Tabs: sliding underline tied to selected tab’s x/width.
  • Menus: scale/opacity entrance; fine-tuned origin to match trigger.
  • Buttons: tap compress (96–98%) + quick rebound.
  • Cards: parallax tilt on pointer move (1–2°), never block clicks.

Reduced motion & accessibility

Honor user preferences and keep focus visible.

@media (prefers-reduced-motion: reduce) {
  * {
    animation: none !important;
    transition: none !important;
  }
}
// Provide a manual toggle (app settings)
export function MotionToggle() {
  // persist choice; fall back to prefers-reduced-motion
  return null;
}
  • Avoid large zooms/long parallax.
  • Ensure trap focus in modals; escape and outside click should close.

Performance guardrails

  • Animate transform and opacity, not layout.
  • Limit concurrent animations; stagger lists for readability.
  • Use will-change sparingly for complex scenes.
  • Keep durations short; long easing feels sluggish.
  • Defer non-critical JS; prefer server components with small client islands.

Testing motion (unit, a11y, e2e)

  • jest-axe: accessibility violations.
  • React Testing Library: roles/labels and visible states.
  • Playwright: keyboard paths + visual regression on transitions.
  • Snapshot states for open/closed and hover/focus/active.
// example pseudo-tests
// 1) toast mounts/unmounts with presence
// 2) modal traps focus and closes on Escape
// 3) reduced-motion disables animation classes

Patterns catalog (copy-ready)

Staggered list

"use client";
import { motion } from "framer-motion";
 
export function StaggerList({ items }: { items: string[] }) {
  return (
    <motion.ul initial="initial" animate="enter" className="space-y-2">
      {items.map((t, i) => (
        <motion.li
          key={t}
          variants={{
            initial: { opacity: 0, y: 6 },
            enter: { opacity: 1, y: 0, transition: { delay: i * 0.06 } },
          }}
          className="rounded-md border px-3 py-2"
        >
          {t}
        </motion.li>
      ))}
    </motion.ul>
  );
}

Sliding tabs underline

"use client";
import { motion } from "framer-motion";
import * as React from "react";
 
export function Tabs({ tabs }: { tabs: string[] }) {
  const [active, setActive] = React.useState(0);
  const refs = React.useRef<HTMLButtonElement[]>([]);
  const [rect, setRect] = React.useState({ x: 0, w: 0 });
 
  React.useEffect(() => {
    const el = refs.current[active];
    if (el) setRect({ x: el.offsetLeft, w: el.offsetWidth });
  }, [active]);
 
  return (
    <div className="relative">
      <div className="flex gap-4">
        {tabs.map((t, i) => (
          <button
            key={t}
            ref={(el) => (refs.current[i] = el!)}
            onClick={() => setActive(i)}
            className="px-2 py-1"
          >
            {t}
          </button>
        ))}
      </div>
      <motion.div
        className="absolute -bottom-0.5 h-0.5 bg-current"
        animate={{ x: rect.x, width: rect.w }}
        transition={{ type: "spring", stiffness: 380, damping: 40 }}
      />
    </div>
  );
}

Drawer (overlay)

"use client";
import { AnimatePresence, motion } from "framer-motion";
 
export function Drawer({
  open,
  onClose,
  children,
}: {
  open: boolean;
  onClose: () => void;
  children: React.ReactNode;
}) {
  return (
    <AnimatePresence>
      {open && (
        <>
          <motion.div
            className="fixed inset-0 bg-black/40"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
          />
          <motion.aside
            className="fixed right-0 top-0 h-full w-[360px] max-w-[92vw] bg-white p-4 shadow-xl"
            initial={{ x: 40, opacity: 0 }}
            animate={{ x: 0, opacity: 1 }}
            exit={{ x: 40, opacity: 0 }}
            transition={{ duration: 0.26 }}
          >
            {children}
          </motion.aside>
        </>
      )}
    </AnimatePresence>
  );
}

Decision matrix: CSS vs Framer Motion vs others

ApproachBest forProsConsiderations
CSS transitionsHovers, small reveals, focus/activeZero deps, fast, declarativeLimited orchestration/gestures
Framer MotionPresence, variants, gestures, orchestrationPowerful API, great DX, SSR‑friendlyAdds a dependency
React SpringPhysics‑heavy, chained springsNatural feel, compositionAPI style preference, bundle
Motion OneWeb Animations API wrapperTiny, native WAAPISmaller ecosystem

FAQ

Do animations hurt Core Web Vitals?
Not if you animate transform/opacity and keep durations short. Avoid layout thrash and large blurs.

How do I support reduced motion?
Respect prefers-reduced-motion, provide a manual toggle, and avoid large zoom/parallax.

What if I already use a UI kit?
Layer motion on top of components—don’t fork the kit. Prefer class hooks or wrapper components that keep upgrade paths clean.


Next steps

  • Add motion tokens to your design system.
  • Implement route transitions and a toast system with presence.
  • Build a patterns gallery (tabs underline, drawer, stagger list) to reuse across your app.

Read more like this

React UI Animation: Principles, Patterns, and Production Recipes (2025) | Ruixen UI