Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/motion-advanced/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-14 | ✗→✓ | ▲ Improved | — | — |
| case-05 | ✗→✓ | ▲ Improved | — | — |
| case-01 | ✗→✓ | ▲ Improved | — | — |
| case-19 | ✗→✓ | ▲ Improved | — | — |
| case-03 | ✗→✓ | ▲ Improved | — | — |
Complex, interactive, and physics-based animation patterns. Requires motion-foundations to be set up first. Use these when motion-patterns is not enough.
useScrollReveal, magnetic button, cursor follower)useAnimateThis skill produces:
Reorder.Group listsuseScrollReveal, useHoverScale, useNavigationDirection, useInViewOnceuseAnimate with interrupt-safe async/awaituseSpring, 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.useMotionValue, useSpring) are SSR-safe and do not cause hydration errors.drag prop works on both but feel and threshold differ.document.visibilityState === "hidden". Background tabs must not consume GPU/CPU.offset + velocity checks.useAnimate scope ref must be attached to a mounted DOM element. Calling animate() before mount throws silently.useMotionValue(0) inside a component body is correct; new MotionValue(0) in a render is not.motion-foundations. No inline numbers.window.addEventListener needs a matching removeEventListener in the useEffect return.| 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 |
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 |
Reactive computation without re-renders:
tsxconst 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
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.
tsxconst [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>
tsx"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} />
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 ( <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() }} /> ) }
tsx"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> ) }
tsx"use client" import { motion } from "motion/react" const OFFSET_THRESHOLD = 50 const 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() }} />
tsximport { 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), } }
tsx"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> ) }
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<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} /> }
tsx"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 }} />
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 ( <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> ) }
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() <motion.section ref={ref} style={style} />
tsx"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 }} /> ) }
tsx"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> ) }
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 ( <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> ) }
tsx"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} /> }
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 ( <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> ) }
This skill does not cover:
motion-foundationsmotion-patternsanimate-* without motion/react| 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 |
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.| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-23 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | 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 +35 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.