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
- Why motion (and when not to use it)
- Durations, easings, and motion tokens
- CSS first: fast, simple wins
- Framer Motion: variants, presence, gestures
- Route transitions in the App Router
- Micro-interactions that matter
- Reduced motion & accessibility
- Performance guardrails
- Testing motion (unit, a11y, e2e)
- Patterns catalog (copy-ready)
- Decision matrix: CSS vs Framer Motion vs others
- 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-motionVariants (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-shadowchange), 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-changesparingly 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 classesPatterns 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
| Approach | Best for | Pros | Considerations |
|---|---|---|---|
| CSS transitions | Hovers, small reveals, focus/active | Zero deps, fast, declarative | Limited orchestration/gestures |
| Framer Motion | Presence, variants, gestures, orchestration | Powerful API, great DX, SSR‑friendly | Adds a dependency |
| React Spring | Physics‑heavy, chained springs | Natural feel, composition | API style preference, bundle |
| Motion One | Web Animations API wrapper | Tiny, native WAAPI | Smaller 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.

