diff --git a/skills/motion-advanced/SKILL.md b/skills/motion-advanced/SKILL.md new file mode 100644 index 00000000..a9d76ede --- /dev/null +++ b/skills/motion-advanced/SKILL.md @@ -0,0 +1,562 @@ +--- +name: motion-advanced +description: Advanced motion patterns for React / Next.js — drag & drop, gestures, text animations, SVG path drawing, custom hooks, imperative sequences (useAnimate), loaders, and the full API decision tree. Requires motion-foundations. +version: 1.0 +tags: [motion, animation, advanced, gestures, svg] +category: frontend +author: jeff +--- + +# Motion Advanced + +Complex, interactive, and physics-based animation patterns. +Requires `motion-foundations` to be set up first. +Use these when `motion-patterns` is not enough. + +## When to Activate + +- Building drag-to-dismiss sheets, swipe gestures, or reorderable lists +- Animating text word-by-word, character-by-character, or as a live counter +- Drawing SVG paths, morphing icons, or animating circular progress +- Writing a custom animation hook (`useScrollReveal`, magnetic button, cursor follower) +- Sequencing multi-step animations imperatively with `useAnimate` +- Building spinners, shimmer skeletons, pulse indicators, or loading button states + +## Outputs + +This skill produces: + +- Drag interactions: draggable cards, drag-to-dismiss sheets, `Reorder.Group` lists +- Gesture hooks: swipe detection, long press, pinch outline +- Text animation components: word reveal, character typewriter, number counter +- SVG animation: path draw-on, icon morph, stroke progress ring +- Custom hooks: `useScrollReveal`, `useHoverScale`, `useNavigationDirection`, `useInViewOnce` +- Imperative sequences via `useAnimate` with interrupt-safe `async/await` +- Loader components: spinner, shimmer, pulse dot, progress bar, button loading state + +## Principles + +- Physics-based motion (`useSpring`, `springs.*`) always feels more natural than duration-based for direct manipulation. +- `useMotionValue` + `useTransform` computes derived values without triggering re-renders. +- `useAnimate` sequences are imperative and interrupt-safe — calling `animate()` mid-flight cancels the previous animation automatically. +- Motion values (`useMotionValue`, `useSpring`) are SSR-safe and do not cause hydration errors. + +## Rules + +1. **Drag interactions must be tested on touch devices**, not just mouse. `drag` prop works on both but feel and threshold differ. +2. **Infinite animations must pause when `document.visibilityState === "hidden"`.** Background tabs must not consume GPU/CPU. +3. **Swipe threshold must be explicit.** Never infer intent from velocity alone; combine `offset` + `velocity` checks. +4. **`useAnimate` scope ref must be attached to a mounted DOM element.** Calling `animate()` before mount throws silently. +5. **Motion values must not be recreated on render.** `useMotionValue(0)` inside a component body is correct; `new MotionValue(0)` in a render is not. +6. **All token values are imported from `motion-foundations`.** No inline numbers. +7. **Custom hooks must handle cleanup.** Every `window.addEventListener` needs a matching `removeEventListener` in the `useEffect` return. +8. **SVG morphing requires equal path command counts.** Paths with different command structures snap instead of interpolating. + +## Decision Guidance + +### Choosing the right advanced API + +| Scenario | API | +| ------------------------------ | -------------------------------- | +| Drag with physics on release | `drag` + `dragTransition: springs.release` | +| Ordered drag-to-reorder list | `Reorder.Group` + `Reorder.Item` | +| Dismiss on drag offset | `drag="y"` + `onDragEnd` offset check | +| Swipe left/right | `drag="x"` + `onDragEnd` offset check | +| Long press | `useLongPress` hook | +| Value smoothed over time | `useSpring` | +| Value derived from another | `useTransform` | +| Multi-step sequence | `useAnimate` with `async/await` | +| One-shot imperative animation | `animate()` from `motion` | +| Text entering word by word | Stagger on `inline-block` spans | +| SVG drawing on | `pathLength` 0 → 1 | +| SVG morph | `d` attribute tween (equal commands) | +| Circular progress | `strokeDashoffset` tween | + +### When to use `useSpring` vs a spring transition + +| | `useSpring` | `transition: springs.*` | +| -------------- | ---------------------------------------- | ----------------------- | +| Use for | Cursor follower, pointer-tracked values | Discrete state changes | +| Updates | Continuous, on every frame | Triggered by state change | +| Interrupt | Smooth — physics picks up from velocity | Restarts from current value | + +## Core Concepts + +### useMotionValue + useTransform + +Reactive computation without re-renders: + +```tsx +const x = useMotionValue(0) +const opacity = useTransform(x, [-200, 0, 200], [0, 1, 0]) +// opacity updates every frame as x changes — no setState, no re-render +``` + +### useAnimate + +Returns `[scope, animate]`. The scope ref must be attached to a DOM element. +`animate()` calls are interrupt-safe — calling mid-flight cancels the previous run. + +```tsx +const [scope, animate] = useAnimate() + +async function play() { + await animate(".step-1", { opacity: 1 }, { duration: 0.3 }) + await animate(".step-2", { x: 0 }, { duration: 0.4 }) + animate(".step-3", { scale: 1 }, { duration: 0.25 }) // fire and forget +} + +return
...
+``` + +## Code Examples + +### Draggable card + +```tsx +"use client" +import { motion } from "motion/react" +import { springs, motionTokens } from "@/lib/motion-tokens" + + +``` + +### Drag-to-dismiss sheet + +```tsx +"use client" +import { motion, useMotionValue, useTransform } from "motion/react" + +export function BottomSheet({ onClose }: { onClose: () => void }) { + const y = useMotionValue(0) + const opacity = useTransform(y, [0, 200], [1, 0]) + + return ( + { + // Rule 3: combine offset + velocity + if (info.offset.y > 120 || info.velocity.y > 500) onClose() + }} + /> + ) +} +``` + +### Reorderable list + +```tsx +"use client" +import { Reorder } from "motion/react" + +export function SortableList() { + const [items, setItems] = useState(initialItems) + return ( + + {items.map((item) => ( + + {item.label} + + ))} + + ) +} +``` + +### Swipe detection + +```tsx +"use client" +import { motion } from "motion/react" + +const OFFSET_THRESHOLD = 50 +const VELOCITY_THRESHOLD = 300 + + { + const swipedRight = info.offset.x > OFFSET_THRESHOLD || info.velocity.x > VELOCITY_THRESHOLD + const swipedLeft = info.offset.x < -OFFSET_THRESHOLD || info.velocity.x < -VELOCITY_THRESHOLD + if (swipedRight) onSwipeRight() + if (swipedLeft) onSwipeLeft() + }} +/> +``` + +### Long press hook + +```tsx +import { useRef } from "react" + +export function useLongPress(callback: () => void, ms = 600) { + const timerRef = useRef>() + return { + onPointerDown: () => { timerRef.current = setTimeout(callback, ms) }, + onPointerUp: () => clearTimeout(timerRef.current), + onPointerLeave: () => clearTimeout(timerRef.current), + } +} +``` + +### Word-by-word reveal + +```tsx +"use client" +import { motion } from "motion/react" +import { springs } from "@/lib/motion-tokens" + +export function AnimatedText({ text }: { text: string }) { + return ( + + {text.split(" ").map((word, i) => ( + + {word} + + ))} + + ) +} +``` + +### Number counter + +```tsx +"use client" +import { useRef, useEffect } from "react" +import { animate } from "motion" +import { motionTokens } from "@/lib/motion-tokens" + +export function Counter({ to }: { to: number }) { + const nodeRef = useRef(null) + + useEffect(() => { + const controls = animate(0, to, { + duration: motionTokens.duration.crawl, + ease: motionTokens.easing.smooth, + onUpdate: (v) => { + if (nodeRef.current) nodeRef.current.textContent = Math.round(v).toString() + }, + }) + return controls.stop // Rule 7: cleanup + }, [to]) + + return +} +``` + +### SVG path draw-on + +```tsx +"use client" +import { motion } from "motion/react" +import { motionTokens } from "@/lib/motion-tokens" + + +``` + +### Stroke progress ring + +```tsx +"use client" +import { motion } from "motion/react" +import { motionTokens } from "@/lib/motion-tokens" + +const CIRCUMFERENCE = 2 * Math.PI * 40 // r=40 + +export function ProgressRing({ progress }: { progress: number }) { + return ( + + + + + ) +} +``` + +### useScrollReveal hook + +```tsx +"use client" +import { useRef } from "react" +import { useScroll, useTransform } from "motion/react" +import { motionTokens } from "@/lib/motion-tokens" + +export function useScrollReveal() { + const ref = useRef(null) + const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] }) + const opacity = useTransform(scrollYProgress, [0, 0.3], [0, 1]) + const y = useTransform(scrollYProgress, [0, 0.3], [motionTokens.distance.lg, 0]) + return { ref, style: { opacity, y } } +} + +// Usage +const { ref, style } = useScrollReveal() + +``` + +### Cursor follower + +```tsx +"use client" +import { useEffect } from "react" +import { motion, useMotionValue, useSpring } from "motion/react" + +export function CursorFollower() { + const x = useMotionValue(-100) + const y = useMotionValue(-100) + const sx = useSpring(x, { stiffness: 120, damping: 16 }) + const sy = useSpring(y, { stiffness: 120, damping: 16 }) + + useEffect(() => { + const move = (e: MouseEvent) => { x.set(e.clientX); y.set(e.clientY) } + window.addEventListener("mousemove", move) + return () => window.removeEventListener("mousemove", move) // Rule 7 + }, []) + + return ( + + ) +} +``` + +### Shimmer skeleton + +```tsx +"use client" +import { motion } from "motion/react" + +export function ShimmerSkeleton({ className }: { className?: string }) { + return ( +
+ +
+ ) +} +``` + +### Button loading state + +```tsx +"use client" +import { motion, AnimatePresence } from "motion/react" +import { motionTokens, springs } from "@/lib/motion-tokens" + +export function LoadingButton({ + loading, + label, + onClick, +}: { + loading: boolean + label: string + onClick: () => void +}) { + return ( + + + {loading ? ( + + … + + ) : ( + + {label} + + )} + + + ) +} +``` + +### Infinite animation with visibility pause + +```tsx +"use client" +import { useEffect, useRef } from "react" +import { motion, useAnimation } from "motion/react" + +export function PulseDot() { + const controls = useAnimation() + + useEffect(() => { + const pulse = () => + controls.start({ + scale: [1, 1.4, 1], + opacity: [1, 0.6, 1], + transition: { repeat: Infinity, duration: 1.8 }, + }) + + // Rule 2: pause when tab is hidden + const handleVisibility = () => { + if (document.visibilityState === "hidden") controls.stop() + else pulse() + } + + pulse() + document.addEventListener("visibilitychange", handleVisibility) + return () => document.removeEventListener("visibilitychange", handleVisibility) // Rule 7 + }, []) + + return +} +``` + +## End-to-End Example + +Drag-to-dismiss sheet with shimmer content, loading state, and reduced motion +support — combining `useMotionValue`, `useTransform`, `useSafeMotion`, +`AnimatePresence`, and tokens from `motion-foundations`: + +```tsx +"use client" +import { useState } from "react" +import { motion, AnimatePresence, useMotionValue, useTransform } from "motion/react" +import { springs, motionTokens } from "@/lib/motion-tokens" +import { useSafeMotion } from "@/hooks/use-reduced-motion" +import { ShimmerSkeleton } from "./shimmer-skeleton" + +export function DismissibleSheet({ + isOpen, + onClose, + loading, + children, +}: { + isOpen: boolean + onClose: () => void + loading: boolean + children: React.ReactNode +}) { + const safe = useSafeMotion(motionTokens.distance.xl) + const y = useMotionValue(0) + const opacity = useTransform(y, [0, 200], [1, 0]) + + return ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Sheet — drag-to-dismiss */} + { + if (info.offset.y > 120 || info.velocity.y > 500) onClose() + }} + initial={safe.initial} + animate={safe.animate} + exit={safe.exit} + transition={springs.gentle} + > + {loading ? ( +
+ + + +
+ ) : children} +
+ + )} +
+ ) +} +``` + +## Constraints / Non-Goals + +This skill does **not** cover: + +- Token and spring definitions → see `motion-foundations` +- Standard UI patterns (button, modal, stagger, page transitions) → see `motion-patterns` +- CSS-only animations or Tailwind `animate-*` without `motion/react` +- Canvas or WebGL-based animation (Three.js, Pixi, etc.) +- Full drag-and-drop systems with external state managers (dnd-kit, react-beautiful-dnd) +- Game-loop or frame-by-frame animation + +## Anti-Patterns + +| Anti-pattern | Rule violated | Fix | +| ---------------------------------------------- | ------- | ------------------------------------------------ | +| `drag` tested only on desktop | Rule 1 | Test on touch emulator and real device | +| `animate={{ repeat: Infinity }}` with no pause | Rule 2 | Add `visibilitychange` listener | +| `onDragEnd` checking only offset, not velocity | Rule 3 | Check both `info.offset` and `info.velocity` | +| `animate(scope, ...)` before `useEffect` | Rule 4 | Call `animate()` only after mount | +| `const x = new MotionValue(0)` in render | Rule 5 | Use `const x = useMotionValue(0)` | +| `transition={{ duration: 1.2 }}` inline | Rule 6 | Use `motionTokens.duration.crawl` | +| `useEffect` without cleanup | Rule 7 | Return `removeEventListener` / `controls.stop` | +| SVG morph between paths with different commands | Rule 8 | Normalize path commands before animating | + +## Related Skills + +- **`motion-foundations`** — defines all tokens, springs, `useSafeMotion`, and SSR guards imported here. Must be set up before using this skill. +- **`motion-patterns`** — handles standard UI patterns (button, modal, stagger, page transitions, scroll reveals). Use it before reaching for the advanced patterns here.