motion-advanced

Advanced motion patterns for React / Next.js — drag & drop, gestures, text animations, SVG path drawing, custom hooks, imperative sequences (useAnimate),…

INSTALLATION
npx skills add https://github.com/affaan-m/ecc --skill motion-advanced
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

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

  • Drag interactions must be tested on touch devices, not just mouse. drag prop 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 + velocity checks.
  • **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.addEventListener needs a matching removeEventListener in the useEffect return.
  • SVG morphing requires equal path command counts. Paths with different command structures snap instead of interpolating.

Decision Guidance

Choosing the right advanced API

ScenarioAPI
Drag with physics on releasedrag + dragTransition: springs.release
Ordered drag-to-reorder listReorder.Group + Reorder.Item
Dismiss on drag offsetdrag="y" + onDragEnd offset check
Swipe left/rightdrag="x" + onDragEnd offset check
Long pressuseLongPress hook
Value smoothed over timeuseSpring
Value derived from anotheruseTransform
Multi-step sequenceuseAnimate with async/await
One-shot imperative animationanimate() from motion
Text entering word by wordStagger on inline-block spans
SVG drawing onpathLength 0 → 1
SVG morphd attribute tween (equal commands)
Circular progressstrokeDashoffset tween

When to use useSpring vs a spring transition

useSpringtransition: springs.*
Use forCursor follower, pointer-tracked valuesDiscrete state changes
UpdatesContinuous, on every frameTriggered by state change
InterruptSmooth — physics picks up from velocityRestarts 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.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  = 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()

  }}

/>

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 } }

}

// Usage

const { 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-* 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-patternRule violatedFix
drag tested only on desktopRule 1Test on touch emulator and real device
animate={{ repeat: Infinity }} with no pauseRule 2Add visibilitychange listener
onDragEnd checking only offset, not velocityRule 3Check both info.offset and info.velocity
animate(scope, ...) before useEffectRule 4Call animate() only after mount
const x = new MotionValue(0) in renderRule 5Use const x = useMotionValue(0)
transition={{ duration: 1.2 }} inlineRule 6Use motionTokens.duration.crawl
useEffect without cleanupRule 7Return removeEventListener / controls.stop
SVG morph between paths with different commandsRule 8Normalize 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.
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card