diff --git a/skills/motion-patterns/SKILL.md b/skills/motion-patterns/SKILL.md new file mode 100644 index 00000000..eb542ad9 --- /dev/null +++ b/skills/motion-patterns/SKILL.md @@ -0,0 +1,420 @@ +--- +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 { 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 { 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.