Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/motion-patterns/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-16 | ✗→✓ | ▲ Improved | — | — |
| case-11 | ✗→✓ | ▲ Improved | — | — |
| case-12 | ✗→✓ | ▲ Improved | — | — |
| case-15 | ✗→✓ | ▲ Improved | — | — |
| case-02 | ✗→✓ | ▲ Improved | — | — |
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.
This skill produces:
AnimatePresence-wrapped conditional renders with correct exit behavioruseScroll + useTransformlayout, layoutId) for expanding and crossfading elementsmotion-foundations. No raw numbers.AnimatePresence with a key.layout is used only for small, isolated shifts. Large subtrees get explicit transforms.AnimatePresence with a key on the direct child. Without a key, exit animations never fire.exit when defining initial + animate. An animation without an exit is incomplete.mode="wait" on page transitions. Enter must not start until exit completes.layout on subtrees with more than ~5 children or deeply nested DOM. Use explicit x/y transforms instead.0.05s and 0.10s. Below feels mechanical; above feels sluggish.role="dialog", aria-modal="true".viewport={{ once: true }}. Repeating on scroll-out is distracting, not informative.motion-foundations. No inline numbers.| 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 |
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 |
Three things must always be true:
AnimatePresence wraps the conditionalkeyexit propMiss any one of these and the exit animation silently fails.
layout — animates the element's own size/position change in placelayoutId — links two separate elements, crossfading between them across rendersUse layout="position" on text inside an expanding container to prevent text reflow from animating.
tsx"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} />
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 }, } <motion.ul variants={container} initial="hidden" animate="visible"> {items.map((i) => ( <motion.li key={i.id} variants={item} /> ))} </motion.ul>
tsx"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.div className="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.div role="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} /> </> ) }
tsx"use client" import { motion, AnimatePresence } from "motion/react" import { motionTokens, springs } from "@/lib/motion-tokens" <AnimatePresence mode="sync"> {toasts.map((t) => ( <motion.div key={t.id} layout initial={{ 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>
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 ( <AnimatePresence mode="wait"> <motion.div key={pathname} variants={variants} initial="initial" animate="enter" exit="exit" transition={{ duration: motionTokens.duration.normal, ease: motionTokens.easing.smooth, }} > {children} </motion.div> </AnimatePresence> ) }
tsx"use client" import { motion } from "motion/react" import { motionTokens, springs } from "@/lib/motion-tokens" <motion.div initial={{ opacity: 0, y: motionTokens.distance.lg }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-80px" }} // once: true — rule 7 transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }} />
tsx"use client" import { motion, useScroll } from "motion/react" export function ScrollProgress() { const { scrollYProgress } = useScroll() return ( <motion.div className="fixed top-0 left-0 h-1 bg-indigo-500 origin-left w-full" style={{ scaleX: scrollYProgress }} /> ) }
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 ( <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.p key="body" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: motionTokens.duration.fast }} > {body} </motion.p> )} </AnimatePresence> </motion.div> ) }
tsx// 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" />
tsx<motion.div initial={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>
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 ( <motion.li variants={{ 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.ul variants={containerVariants} initial="hidden" animate="visible" className="space-y-2" > <AnimatePresence mode="popLayout"> {items.map((item) => ( <ListItem key={item.id} label={item.label} onRemove={() => onRemove(item.id)} /> ))} </AnimatePresence> </motion.ul> ) }
This skill does not cover:
motion-foundationsmotion-advancedmotion-advancedmotion-advancedmotion-advancedmotion/react| 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 |
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.| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-16 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | pass→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-23 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +43 percentage points is the difference between those two pass rates over the 23 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.