motion-advanced
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.
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.Grouplists - 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
useAnimatewith interrupt-safeasync/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+useTransformcomputes derived values without triggering re-renders.useAnimatesequences are imperative and interrupt-safe — callinganimate()mid-flight cancels the previous animation automatically.- Motion values (
useMotionValue,useSpring) are SSR-safe and do not cause hydration errors.
Rules
- Drag interactions must be tested on touch devices, not just mouse.
dragprop works on both but feel and threshold differ. - Infinite animations must pause when
document.visibilityState === "hidden". Background tabs must not consume GPU/CPU. - Swipe threshold must be explicit. Never infer intent from velocity alone; combine
offset+velocitychecks. useAnimatescope ref must be attached to a mounted DOM element. Callinganimate()before mount throws silently.- Motion values must not be recreated on render.
useMotionValue(0)inside a component body is correct;new MotionValue(0)in a render is not. - All token values are imported from
motion-foundations. No inline numbers. - Custom hooks must handle cleanup. Every
window.addEventListenerneeds a matchingremoveEventListenerin theuseEffectreturn. - 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:
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-renderuseAnimate
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.
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 <div ref={scope}>...</div>Code Examples
Draggable card
"use client"import { motion } from "motion/react"import { springs, motionTokens } from "@/lib/motion-tokens"
<motion.div drag dragConstraints={{ left: -100, right: 100, top: -100, bottom: 100 }} dragElastic={0.1} whileDrag={{ scale: motionTokens.scale.pop, boxShadow: "0 16px 40px rgba(0,0,0,0.2)", }} dragTransition={springs.release}/>Drag-to-dismiss sheet
"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 ( <motion.div drag="y" dragConstraints={{ top: 0 }} style={{ y, opacity }} onDragEnd={(_, info) => { // Rule 3: combine offset + velocity if (info.offset.y > 120 || info.velocity.y > 500) onClose() }} /> )}Reorderable list
"use client"import { Reorder } from "motion/react"
export function SortableList() { const [items, setItems] = useState(initialItems) return ( <Reorder.Group axis="y" values={items} onReorder={setItems}> {items.map((item) => ( <Reorder.Item key={item.id} value={item}> {item.label} </Reorder.Item> ))} </Reorder.Group> )}Swipe detection
"use client"import { motion } from "motion/react"
const OFFSET_THRESHOLD = 50const VELOCITY_THRESHOLD = 300
<motion.div drag="x" dragConstraints={{ left: 0, right: 0 }} onDragEnd={(_, info) => { 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
import { useRef } from "react"
export function useLongPress(callback: () => void, ms = 600) { const timerRef = useRef<ReturnType<typeof setTimeout>>() return { onPointerDown: () => { timerRef.current = setTimeout(callback, ms) }, onPointerUp: () => clearTimeout(timerRef.current), onPointerLeave: () => clearTimeout(timerRef.current), }}Word-by-word reveal
"use client"import { motion } from "motion/react"import { springs } from "@/lib/motion-tokens"
export function AnimatedText({ text }: { text: string }) { return ( <motion.p variants={{ visible: { transition: { staggerChildren: 0.05 } } }} initial="hidden" animate="visible" > {text.split(" ").map((word, i) => ( <motion.span key={i} className="inline-block mr-1" variants={{ hidden: { opacity: 0, y: 12 }, visible: { opacity: 1, y: 0, transition: springs.gentle }, }} > {word} </motion.span> ))} </motion.p> )}Number counter
"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<HTMLSpanElement>(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 <span ref={nodeRef} />}SVG path draw-on
"use client"import { motion } from "motion/react"import { motionTokens } from "@/lib/motion-tokens"
<motion.path d="M 0 100 Q 50 0 100 100" initial={{ pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }} transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}/>Stroke progress ring
"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 ( <svg width="100" height="100" viewBox="0 0 100 100"> <circle cx="50" cy="50" r="40" fill="none" stroke="#e5e7eb" strokeWidth="8" /> <motion.circle cx="50" cy="50" r="40" fill="none" stroke="#6366f1" strokeWidth="8" strokeLinecap="round" strokeDasharray={CIRCUMFERENCE} animate={{ strokeDashoffset: CIRCUMFERENCE - (progress / 100) * CIRCUMFERENCE }} transition={{ duration: motionTokens.duration.normal, ease: motionTokens.easing.smooth }} style={{ rotate: -90, transformOrigin: "center" }} /> </svg> )}useScrollReveal hook
"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 } }}
// Usageconst { ref, style } = useScrollReveal()<motion.section ref={ref} style={style} />Cursor follower
"use client"import { useEffect } from "react"import { motion, useMotionValue, useSpring } from "motion/react"import { springs } from "@/lib/motion-tokens"
export function CursorFollower() { const x = useMotionValue(-100) const y = useMotionValue(-100) const sx = useSpring(x, springs.gentle) const sy = useSpring(y, springs.gentle)
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 ( <motion.div className="fixed top-0 left-0 w-6 h-6 rounded-full bg-indigo-500 pointer-events-none -translate-x-1/2 -translate-y-1/2 z-50" style={{ x: sx, y: sy }} /> )}Shimmer skeleton
"use client"import { useEffect } from "react"import { motion, useAnimation } from "motion/react"import { motionTokens } from "@/lib/motion-tokens"
export function ShimmerSkeleton({ className = "" }: { className?: string }) { const controls = useAnimation()
useEffect(() => { const play = () => controls.start({ x: ["-100%", "100%"], transition: { repeat: Infinity, duration: motionTokens.duration.crawl, ease: motionTokens.easing.linear, }, })
const handleVisibility = () => { if (document.visibilityState === "hidden") controls.stop() else void play() }
void play() document.addEventListener("visibilitychange", handleVisibility) return () => { controls.stop() document.removeEventListener("visibilitychange", handleVisibility) } }, [controls])
return ( <div className={`relative overflow-hidden bg-gray-200 rounded ${className}`}> <motion.div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/60 to-transparent" initial={{ x: "-100%" }} animate={controls} /> </div> )}Button loading state
"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 ( <motion.button onClick={onClick} animate={{ opacity: loading ? 0.7 : 1 }} whileTap={loading ? {} : { scale: motionTokens.scale.press }} transition={springs.snappy} disabled={loading} > <AnimatePresence mode="wait"> {loading ? ( <motion.span key="loading" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: motionTokens.duration.fast }} > … </motion.span> ) : ( <motion.span key="label" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: motionTokens.duration.fast }} > {label} </motion.span> )} </AnimatePresence> </motion.button> )}Infinite animation with visibility pause
"use client"import { useEffect } from "react"import { motion, useAnimation } from "motion/react"import { motionTokens } from "@/lib/motion-tokens"
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: motionTokens.duration.crawl }, })
// Rule 2: pause when tab is hidden const handleVisibility = () => { if (document.visibilityState === "hidden") controls.stop() else void pulse() }
void pulse() document.addEventListener("visibilitychange", handleVisibility) // Rule 7: stop controls and remove listeners on unmount. return () => { controls.stop() document.removeEventListener("visibilitychange", handleVisibility) } }, [controls])
return <motion.span className="w-2 h-2 rounded-full bg-green-400" animate={controls} />}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:
"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 ( <AnimatePresence> {isOpen && ( <> {/* Backdrop */} <motion.div key="backdrop" className="fixed inset-0 bg-black/40" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} />
{/* Sheet — drag-to-dismiss */} <motion.div key="sheet" className="fixed bottom-0 inset-x-0 rounded-t-2xl bg-white p-6" drag="y" dragConstraints={{ top: 0 }} style={{ y, opacity }} onDragEnd={(_, info) => { if (info.offset.y > 120 || info.velocity.y > 500) onClose() }} initial={safe.initial} animate={safe.animate} exit={safe.exit} transition={springs.gentle} > {loading ? ( <div className="space-y-3"> <ShimmerSkeleton className="h-4 w-3/4" /> <ShimmerSkeleton className="h-4 w-1/2" /> <ShimmerSkeleton className="h-20 w-full" /> </div> ) : children} </motion.div> </> )} </AnimatePresence> )}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-*withoutmotion/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.