
About
Production-ready animation patterns for React / Next.js — button, modal, toast, stagger, page transitions, exit animations, scroll, and layout — built on motion-foundations tokens and springs.
name: motion-patterns description: Production-ready animation patterns for React / Next.js — button, modal, toast, stagger, page transitions, exit animations, scroll, and layout — built on motion-foundations tokens and springs. version: 1.0 tags: [motion, animation, ui-patterns] category: frontend author: jeff
Motion Patterns
Copy-paste patterns for the most common UI animation needs.
Every pattern here is built on motion-foundations tokens and springs.
Do not define new duration or easing values here — import them.
When to Activate
- Animating a button, card, modal, or toast notification
- Building list entrances with stagger
- Setting up page transitions in Next.js App Router
- Adding entrance or exit animations to conditional content
- Implementing scroll-reveal, scroll-linked progress, or sticky story sections
- Building expanding cards, accordions, or shared-element transitions
Outputs
This skill produces:
- Accessible, SSR-safe animation for all standard UI components
AnimatePresence-wrapped conditional renders with correct exit behavior- Page transition wrapper component for Next.js App Router
- Scroll-reveal and scroll-linked patterns using
useScroll+useTransform - Layout animation patterns (
layout,layoutId) for expanding and crossfading elements
Principles
- Every pattern imports from
motion-foundations. No raw numbers. - Every conditional render is wrapped in
AnimatePresencewith akey. - Exit animations are always defined alongside enter animations — never as an afterthought.
layoutis used only for small, isolated shifts. Large subtrees get explicit transforms.
Rules
- Always wrap conditional renders in
AnimatePresencewith akeyon the direct child. Without a key, exit animations never fire. - Always define
exitwhen defininginitial+animate. An animation without an exit is incomplete. - Use
mode="wait"on page transitions. Enter must not start until exit completes. - Never use
layouton subtrees with more than ~5 children or deeply nested DOM. Use explicitx/ytransforms instead. - Stagger interval must stay between
0.05sand0.10s. Below feels mechanical; above feels sluggish. - Modals must always include: focus trap, Escape-key close, scroll lock,
role="dialog",aria-modal="true". - Scroll reveals use
viewport={{ once: true }}. Repeating on scroll-out is distracting, not informative. - All token values are imported from
motion-foundations. No inline numbers.
Decision Guidance
Choosing the right pattern
| Situation | Pattern |
| ---------------------------------------- | ---------------------- |
| Element appears / disappears | AnimatePresence |
| List of items loading in sequence | Stagger variants |
| Navigating between routes | Page transition wrapper|
| Element changes size in place | layout prop |
| Same element moves across page contexts | layoutId |
| Element enters when scrolled into view | whileInView |
| Value tied to scroll position | useScroll + useTransform |
When to use mode="wait" vs mode="sync"
| Mode | Use when |
| ------- | --------------------------------------- |
| wait | Page transitions, content swaps (one at a time) |
| sync | Stacked notifications, list items (overlap is fine) |
| popLayout | Items removed from a reflow list |
Core Concepts
AnimatePresence contract
Three things must always be true:
AnimatePresencewraps the conditional- The direct child has a
key - The child has an
exitprop
Miss any one of these and the exit animation silently fails.
layout vs layoutId
layout— animates the element's own size/position change in placelayoutId— links two separate elements, crossfading between them across renders
Use layout="position" on text inside an expanding container to prevent text reflow from animating.
Code Examples
Button feedback
"use client"
import { motion } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"
<motion.button
whileHover={{ scale: motionTokens.scale.pop }}
whileTap={{ scale: motionTokens.scale.press }}
transition={springs.snappy}
/>
Stagger list
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
const container = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.08, // within the 0.05–0.10 rule
delayChildren: 0.1,
},
},
}
const item = {
hidden: { opacity: 0, y: motionTokens.distance.md },
visible: { opacity: 1, y: 0, transition: springs.gentle },
}
<motion.ul variants={container} initial="hidden" animate="visible">
{items.map((i) => (
<motion.li key={i.id} variants={item} />
))}
</motion.ul>
Modal
"use client"
import { motion, AnimatePresence
