Skill v1.0.1
currentAutomated scan96/100+3 new
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 `AnimatePresence` with a `key` on the direct child. Without a key, exit animations never fire.
- Always define `exit` when defining `initial` + `animate`. An animation without an exit is incomplete.
- Use `mode="wait"` on page transitions. Enter must not start until exit completes.
- Never use `layout` on subtrees with more than ~5 children or deeply nested DOM. Use explicit
x/ytransforms instead. - Stagger interval must stay between `0.05s` and `0.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.buttonwhileHover={{ 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 ruledelayChildren: 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 } from "motion/react"import { motionTokens, springs } from "@/lib/motion-tokens"// Wrap at the call site:// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>export function Modal({ onClose }: { onClose: () => void }) {return (<>{/* Overlay */}<motion.divclassName="fixed inset-0 bg-black/50"initial={{ opacity: 0 }}animate={{ opacity: 1 }}exit={{ opacity: 0 }}onClick={onClose}/>{/* Panel — accessibility requirements: focus trap, Escape close,scroll lock, role="dialog", aria-modal="true" */}<motion.divrole="dialog"aria-modal="true"className="fixed inset-x-4 top-1/2 -translate-y-1/2 rounded-xl bg-white p-6"initial={{opacity: 0,scale: motionTokens.scale.press,y: motionTokens.distance.sm,}}animate={{ opacity: 1, scale: 1, y: 0 }}exit={{opacity: 0,scale: motionTokens.scale.press,y: motionTokens.distance.sm,}}transition={springs.gentle}/></>)}
Toast stack
"use client"import { motion, AnimatePresence } from "motion/react"import { motionTokens, springs } from "@/lib/motion-tokens"<AnimatePresence mode="sync">{toasts.map((t) => (<motion.divkey={t.id}layoutinitial={{opacity: 0,x: motionTokens.distance.xl,scale: motionTokens.scale.subtle,}}animate={{ opacity: 1, x: 0, scale: 1 }}exit={{opacity: 0,x: motionTokens.distance.xl,scale: motionTokens.scale.subtle,}}transition={springs.snappy}/>))}</AnimatePresence>
Page transition (Next.js App Router)
// components/page-transition.tsx"use client"import { motion, AnimatePresence } from "motion/react"import { usePathname } from "next/navigation"import { motionTokens } from "@/lib/motion-tokens"const variants = {initial: { opacity: 0, y: motionTokens.distance.sm },enter: { opacity: 1, y: 0 },exit: { opacity: 0, y: -motionTokens.distance.sm },}export function PageTransition({ children }: { children: React.ReactNode }) {const pathname = usePathname()return (<AnimatePresence mode="wait"><motion.divkey={pathname}variants={variants}initial="initial"animate="enter"exit="exit"transition={{duration: motionTokens.duration.normal,ease: motionTokens.easing.smooth,}}>{children}</motion.div></AnimatePresence>)}
Scroll reveal
"use client"import { motion } from "motion/react"import { motionTokens, springs } from "@/lib/motion-tokens"<motion.divinitial={{ opacity: 0, y: motionTokens.distance.lg }}whileInView={{ opacity: 1, y: 0 }}viewport={{ once: true, margin: "-80px" }} // once: true — rule 7transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}/>
Scroll progress bar
"use client"import { motion, useScroll } from "motion/react"export function ScrollProgress() {const { scrollYProgress } = useScroll()return (<motion.divclassName="fixed top-0 left-0 h-1 bg-indigo-500 origin-left w-full"style={{ scaleX: scrollYProgress }}/>)}
Expanding card
"use client"import { useState } from "react"import { motion, AnimatePresence } from "motion/react"import { springs, motionTokens } from "@/lib/motion-tokens"export function ExpandingCard({ title, body }: { title: string; body: string }) {const [expanded, setExpanded] = useState(false)return (<motion.div layout onClick={() => setExpanded(!expanded)} className="cursor-pointer">{/* layout="position" prevents text reflow from animating */}<motion.h2 layout="position" className="font-semibold">{title}</motion.h2><AnimatePresence>{expanded && (<motion.pkey="body"initial={{ opacity: 0 }}animate={{ opacity: 1 }}exit={{ opacity: 0 }}transition={{ duration: motionTokens.duration.fast }}>{body}</motion.p>)}</AnimatePresence></motion.div>)}
Shared-element crossfade
// Source context<motion.img layoutId="hero-image" src={src} className="w-16 h-16 rounded" />// Destination context (same layoutId — motion handles the transition)<motion.img layoutId="hero-image" src={src} className="w-full rounded-xl" />
Accordion
<motion.divinitial={false}animate={{ opacity: open ? 1 : 0, scaleY: open ? 1 : 0 }}style={{ transformOrigin: "top", overflow: "hidden" }}transition={{duration: motionTokens.duration.normal,ease: motionTokens.easing.smooth,}}>{children}</motion.div>
End-to-End Example
A staggered list that enters on mount, handles conditional presence, and respects reduced motion — combining tokens, springs, AnimatePresence, and the accessibility hook from motion-foundations:
"use client"import { useState } from "react"import { motion, AnimatePresence } from "motion/react"import { motionTokens, springs } from "@/lib/motion-tokens"import { useSafeMotion } from "@/hooks/use-reduced-motion"const containerVariants = {hidden: {},visible: {transition: { staggerChildren: 0.08, delayChildren: 0.1 },},}function ListItem({ label, onRemove }: { label: string; onRemove: () => void }) {const safe = useSafeMotion(motionTokens.distance.sm)return (<motion.livariants={{hidden: safe.initial,visible: safe.animate,}}exit={safe.exit}transition={springs.gentle}className="flex items-center justify-between p-3 rounded-lg bg-white shadow-sm"><span>{label}</span><button onClick={onRemove}>Remove</button></motion.li>)}export function AnimatedList({ items, onRemove }: {items: { id: string; label: string }[]onRemove: (id: string) => void}) {return (<motion.ulvariants={containerVariants}initial="hidden"animate="visible"className="space-y-2"><AnimatePresence mode="popLayout">{items.map((item) => (<ListItemkey={item.id}label={item.label}onRemove={() => onRemove(item.id)}/>))}</AnimatePresence></motion.ul>)}
Constraints / Non-Goals
This skill does not cover:
- Token and spring definitions → see
motion-foundations - Drag interactions, swipe gestures, reorderable lists → see
motion-advanced - Text animations (word/character reveal, counters) → see
motion-advanced - SVG path drawing or morphing → see
motion-advanced - Custom animation hooks → see
motion-advanced - CSS-only transitions not using
motion/react
Anti-Patterns
| Anti-pattern | Rule violated | Fix | |
|---|---|---|---|
AnimatePresence child missing key | Rule 1 | Add stable key to the direct child | |
initial + animate without exit | Rule 2 | Always define all three together | |
Page transition without mode="wait" | Rule 3 | Add mode="wait" to AnimatePresence | |
layout on a 50-item list | Rule 4 | Use mode="popLayout" or explicit transforms | |
staggerChildren: 0.2 on a 10-item list | Rule 5 | Cap at 0.08–0.10 | |
| Modal without focus trap | Rule 6 | Add focus-trap-react or Radix Dialog | |
whileInView without viewport={{ once: true }} | Rule 7 | Repeating entrances distract, not inform | |
transition={{ duration: 0.3 }} inline | Rule 8 | Use motionTokens.duration.normal |
Related Skills
- `motion-foundations` — defines all tokens, springs, the
useSafeMotionhook, and SSR guards that every pattern here imports. Must be set up first. - `motion-advanced` — extends these patterns with drag, gestures, SVG, text, custom hooks, and imperative sequencing. Does not redefine any patterns from this skill.