--- 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 `AnimatePresence` with a `key`. - Exit animations are always defined alongside enter animations — never as an afterthought. - `layout` is used only for small, isolated shifts. Large subtrees get explicit transforms. ## Rules 1. **Always wrap conditional renders in `AnimatePresence` with a `key`** on the direct child. Without a key, exit animations never fire. 2. **Always define `exit` when defining `initial` + `animate`.** An animation without an exit is incomplete. 3. **Use `mode="wait"` on page transitions.** Enter must not start until exit completes. 4. **Never use `layout` on subtrees with more than ~5 children or deeply nested DOM.** Use explicit `x`/`y` transforms instead. 5. **Stagger interval must stay between `0.05s` and `0.10s`.** Below feels mechanical; above feels sluggish. 6. **Modals must always include:** focus trap, Escape-key close, scroll lock, `role="dialog"`, `aria-modal="true"`. 7. **Scroll reveals use `viewport={{ once: true }}`.** Repeating on scroll-out is distracting, not informative. 8. **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: 1. `AnimatePresence` wraps the conditional 2. The direct child has a `key` 3. The child has an `exit` prop Miss any one of these and the exit animation silently fails. ### layout vs layoutId - `layout` — animates the element's own size/position change in place - `layoutId` — 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 ```tsx "use client" import { motion } from "motion/react" import { springs, motionTokens } from "@/lib/motion-tokens" ``` ### Stagger list ```tsx "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 }, } {items.map((i) => ( ))} ``` ### Modal ```tsx "use client" import { motion, AnimatePresence } from "motion/react" import { motionTokens, springs } from "@/lib/motion-tokens" // Wrap at the call site: // {isOpen && } export function Modal({ onClose }: { onClose: () => void }) { return ( <> {/* Overlay */} {/* Panel — accessibility requirements: focus trap, Escape close, scroll lock, role="dialog", aria-modal="true" */} ) } ``` ### Toast stack ```tsx "use client" import { motion, AnimatePresence } from "motion/react" import { motionTokens, springs } from "@/lib/motion-tokens" {toasts.map((t) => ( ))} ``` ### Page transition (Next.js App Router) ```tsx // 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 ( {children} ) } ``` ### Scroll reveal ```tsx "use client" import { motion } from "motion/react" import { motionTokens, springs } from "@/lib/motion-tokens" ``` ### Scroll progress bar ```tsx "use client" import { motion, useScroll } from "motion/react" export function ScrollProgress() { const { scrollYProgress } = useScroll() return ( ) } ``` ### Expanding card ```tsx "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 ( setExpanded(!expanded)} className="cursor-pointer"> {/* layout="position" prevents text reflow from animating */} {title} {expanded && ( {body} )} ) } ``` ### Shared-element crossfade ```tsx // Source context // Destination context (same layoutId — motion handles the transition) ``` ### Accordion ```tsx {children} ``` ## 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`: ```tsx "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 ( {label} ) } export function AnimatedList({ items, onRemove }: { items: { id: string; label: string }[] onRemove: (id: string) => void }) { return ( {items.map((item) => ( onRemove(item.id)} /> ))} ) } ``` ## 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 `useSafeMotion` hook, 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.