Skill v1.0.2
currentAutomated scan100/1004 files
version: "1.0.2" 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. Use when building drag and drop, gestures, text or SVG animation, or imperative animation sequences in React or Next.js. tags: [motion, animation, advanced, gestures, svg] category: frontend author: jeff metadata: version: 1.0.0
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. - `useAnimate` scope ref must be attached to a mounted DOM element. Calling
animate()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-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.
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.divdragdragConstraints={{ 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.divdrag="y"dragConstraints={{ top: 0 }}style={{ y, opacity }}onDragEnd={(_, info) => {// Rule 3: combine offset + velocityif (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.divdrag="x"dragConstraints={{ left: 0, right: 0 }}onDragEnd={(_, info) => {const swipedRight = info.offset.x > OFFSET_THRESHOLD || info.velocity.x > VELOCITY_THRESHOLDconst swipedLeft = info.offset.x < -OFFSET_THRESHOLD || info.velocity.x < -VELOCITY_THRESHOLDif (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.pvariants={{ visible: { transition: { staggerChildren: 0.05 } } }}initial="hidden"animate="visible">{text.split(" ").map((word, i) => (<motion.spankey={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.pathd="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=40export 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.circlecx="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.divclassName="fixed top-0 left-0 w-6 h-6 rounded-full bg-indigo-500pointer-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.divclassName="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: booleanlabel: stringonClick: () => void}) {return (<motion.buttononClick={onClick}animate={{ opacity: loading ? 0.7 : 1 }}whileTap={loading ? {} : { scale: motionTokens.scale.press }}transition={springs.snappy}disabled={loading}><AnimatePresence mode="wait">{loading ? (<motion.spankey="loading"initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}transition={{ duration: motionTokens.duration.fast }}>…</motion.span>) : (<motion.spankey="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 hiddenconst 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: booleanonClose: () => voidloading: booleanchildren: 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.divkey="backdrop"className="fixed inset-0 bg-black/40"initial={{ opacity: 0 }}animate={{ opacity: 1 }}exit={{ opacity: 0 }}onClick={onClose}/>{/* Sheet — drag-to-dismiss */}<motion.divkey="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.