mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-14 02:10:07 +08:00
Fix copied example issues from the adopted #1780 motion skills: live reduced-motion config, tokenized distances/easing/springs, valid shimmer skeleton JSX, and visibility cleanup.
597 lines
18 KiB
Markdown
597 lines
18 KiB
Markdown
---
|
|
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 <div ref={scope}>...</div>
|
|
```
|
|
|
|
## Code Examples
|
|
|
|
### Draggable card
|
|
|
|
```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}
|
|
/>
|
|
```
|
|
|
|
### 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 (
|
|
<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
|
|
|
|
```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>
|
|
)
|
|
}
|
|
```
|
|
|
|
### Swipe detection
|
|
|
|
```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()
|
|
}}
|
|
/>
|
|
```
|
|
|
|
### Long press hook
|
|
|
|
```tsx
|
|
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
|
|
|
|
```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>
|
|
)
|
|
}
|
|
```
|
|
|
|
### 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<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
|
|
|
|
```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 }}
|
|
/>
|
|
```
|
|
|
|
### 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 (
|
|
<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
|
|
|
|
```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} />
|
|
```
|
|
|
|
### Cursor follower
|
|
|
|
```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 }}
|
|
/>
|
|
)
|
|
}
|
|
```
|
|
|
|
### Shimmer skeleton
|
|
|
|
```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>
|
|
)
|
|
}
|
|
```
|
|
|
|
### 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 (
|
|
<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
|
|
|
|
```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} />
|
|
}
|
|
```
|
|
|
|
## 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 (
|
|
<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-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.
|