mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-13 18:00:35 +08:00
feat: add motion system skills
Adopts the motion skill content from PR #1780 and syncs the public catalog counts for the current main surface. Co-authored-by: Jeff <peacelord1309@gmail.com>
This commit is contained in:
parent
22aabf7d4f
commit
f219a90f20
@ -11,7 +11,7 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"source": "./",
|
||||
"description": "The most comprehensive Claude Code plugin — 58 agents, 217 skills, 74 legacy command shims, selective install profiles, and production-ready hooks for TDD, security scanning, code review, and continuous learning",
|
||||
"description": "The most comprehensive Claude Code plugin — 58 agents, 220 skills, 74 legacy command shims, selective install profiles, and production-ready hooks for TDD, security scanning, code review, and continuous learning",
|
||||
"version": "2.0.0-rc.1",
|
||||
"author": {
|
||||
"name": "Affaan Mustafa",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"version": "2.0.0-rc.1",
|
||||
"description": "Battle-tested Claude Code plugin for engineering teams — 58 agents, 217 skills, 74 legacy command shims, production-ready hooks, and selective install workflows evolved through continuous real-world use",
|
||||
"description": "Battle-tested Claude Code plugin for engineering teams — 58 agents, 220 skills, 74 legacy command shims, production-ready hooks, and selective install workflows evolved through continuous real-world use",
|
||||
"author": {
|
||||
"name": "Affaan Mustafa",
|
||||
"url": "https://x.com/affaanmustafa"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — Agent Instructions
|
||||
|
||||
This is a **production-ready AI coding plugin** providing 58 specialized agents, 217 skills, 74 commands, and automated hook workflows for software development.
|
||||
This is a **production-ready AI coding plugin** providing 58 specialized agents, 220 skills, 74 commands, and automated hook workflows for software development.
|
||||
|
||||
**Version:** 2.0.0-rc.1
|
||||
|
||||
@ -148,7 +148,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
|
||||
|
||||
```
|
||||
agents/ — 58 specialized subagents
|
||||
skills/ — 217 workflow skills and domain knowledge
|
||||
skills/ — 220 workflow skills and domain knowledge
|
||||
commands/ — 74 slash commands
|
||||
hooks/ — Trigger-based automations
|
||||
rules/ — Always-follow guidelines (common + per-language)
|
||||
|
||||
@ -358,7 +358,7 @@ If you stacked methods, clean up in this order:
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**That's it!** You now have access to 58 agents, 217 skills, and 74 legacy command shims.
|
||||
**That's it!** You now have access to 58 agents, 220 skills, and 74 legacy command shims.
|
||||
|
||||
### Dashboard GUI
|
||||
|
||||
@ -1362,7 +1362,7 @@ The configuration is automatically detected from `.opencode/opencode.json`.
|
||||
|---------|-------------|----------|--------|
|
||||
| Agents | PASS: 58 agents | PASS: 12 agents | **Claude Code leads** |
|
||||
| Commands | PASS: 74 commands | PASS: 35 commands | **Claude Code leads** |
|
||||
| Skills | PASS: 217 skills | PASS: 37 skills | **Claude Code leads** |
|
||||
| Skills | PASS: 220 skills | PASS: 37 skills | **Claude Code leads** |
|
||||
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
|
||||
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
|
||||
| MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** |
|
||||
@ -1467,7 +1467,7 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e
|
||||
|---------|------------|------------|-----------|----------|
|
||||
| **Agents** | 58 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |
|
||||
| **Commands** | 74 | Shared | Instruction-based | 35 |
|
||||
| **Skills** | 217 | Shared | 10 (native format) | 37 |
|
||||
| **Skills** | 220 | Shared | 10 (native format) | 37 |
|
||||
| **Hook Events** | 8 types | 15 types | None yet | 11 types |
|
||||
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks |
|
||||
| **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions |
|
||||
|
||||
@ -160,7 +160,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**完成!** 你现在可以使用 58 个代理、217 个技能和 74 个命令。
|
||||
**完成!** 你现在可以使用 58 个代理、220 个技能和 74 个命令。
|
||||
|
||||
### multi-* 命令需要额外配置
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — 智能体指令
|
||||
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 58 个专业代理、217 项技能、74 条命令以及自动化钩子工作流,用于软件开发。
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 58 个专业代理、220 项技能、74 条命令以及自动化钩子工作流,用于软件开发。
|
||||
|
||||
**版本:** 2.0.0-rc.1
|
||||
|
||||
@ -147,7 +147,7 @@
|
||||
|
||||
```
|
||||
agents/ — 58 个专业子代理
|
||||
skills/ — 217 个工作流技能和领域知识
|
||||
skills/ — 220 个工作流技能和领域知识
|
||||
commands/ — 74 个斜杠命令
|
||||
hooks/ — 基于触发的自动化
|
||||
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
||||
|
||||
@ -224,7 +224,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**搞定!** 你现在可以使用 58 个智能体、217 项技能和 74 个命令了。
|
||||
**搞定!** 你现在可以使用 58 个智能体、220 项技能和 74 个命令了。
|
||||
|
||||
***
|
||||
|
||||
@ -1134,7 +1134,7 @@ opencode
|
||||
|---------|-------------|----------|--------|
|
||||
| 智能体 | PASS: 58 个 | PASS: 12 个 | **Claude Code 领先** |
|
||||
| 命令 | PASS: 74 个 | PASS: 35 个 | **Claude Code 领先** |
|
||||
| 技能 | PASS: 217 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||
| 技能 | PASS: 220 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
|
||||
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
||||
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
|
||||
@ -1242,7 +1242,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
|
||||
|---------|------------|------------|-----------|----------|
|
||||
| **智能体** | 58 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||
| **命令** | 74 | 共享 | 基于指令 | 35 |
|
||||
| **技能** | 217 | 共享 | 10 (原生格式) | 37 |
|
||||
| **技能** | 220 | 共享 | 10 (原生格式) | 37 |
|
||||
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
|
||||
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
|
||||
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
|
||||
|
||||
575
skills/motion-advanced/SKILL.md
Normal file
575
skills/motion-advanced/SKILL.md
Normal file
@ -0,0 +1,575 @@
|
||||
---
|
||||
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: "easeInOut" }}
|
||||
/>
|
||||
```
|
||||
|
||||
### 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"
|
||||
|
||||
export function CursorFollower() {
|
||||
const x = useMotionValue(-100)
|
||||
const y = useMotionValue(-100)
|
||||
const sx = useSpring(x, { stiffness: 120, damping: 16 })
|
||||
const sy = useSpring(y, { stiffness: 120, damping: 16 })
|
||||
|
||||
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 { motion } from "motion/react"
|
||||
|
||||
export function ShimmerSkeleton({ className }: { className?: string }) {
|
||||
const controls = useAnimation()
|
||||
useEffect(() => {
|
||||
const run = () =>
|
||||
controls.start({ x: ["-100%", "100%"], transition: { repeat: Infinity, duration: 1.2, ease: "linear" } })
|
||||
const onVis = () => (document.visibilityState === "hidden" ? controls.stop() : run())
|
||||
run()
|
||||
document.addEventListener("visibilitychange", onVis)
|
||||
return () => document.removeEventListener("visibilitychange", onVis)
|
||||
}, [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"
|
||||
animate={{ x: ["-100%", "100%"] }}
|
||||
transition={{ repeat: Infinity, duration: 1.2, ease: "linear" }}
|
||||
animate={controls}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</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, useRef } from "react"
|
||||
import { motion, useAnimation } from "motion/react"
|
||||
|
||||
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: 1.8 },
|
||||
})
|
||||
|
||||
// Rule 2: pause when tab is hidden
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === "hidden") controls.stop()
|
||||
else pulse()
|
||||
}
|
||||
|
||||
pulse()
|
||||
document.addEventListener("visibilitychange", handleVisibility)
|
||||
return () => document.removeEventListener("visibilitychange", handleVisibility) // Rule 7
|
||||
}, [])
|
||||
|
||||
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.
|
||||
291
skills/motion-foundations/SKILL.md
Normal file
291
skills/motion-foundations/SKILL.md
Normal file
@ -0,0 +1,291 @@
|
||||
---
|
||||
name: motion-foundations
|
||||
description: Motion tokens, spring presets, performance rules, device adaptation, accessibility enforcement, and SSR safety for React / Next.js using motion/react. Foundation layer — all other motion skills depend on this.
|
||||
version: 1.0
|
||||
tags: [motion, animation, performance, accessibility]
|
||||
category: frontend
|
||||
author: jeff
|
||||
---
|
||||
|
||||
# Motion Foundations
|
||||
|
||||
The base layer of the motion system. Defines every value, constraint, and
|
||||
rule that downstream skills (`motion-patterns`, `motion-advanced`) inherit.
|
||||
Load this skill before any animation work begins.
|
||||
|
||||
## When to Activate
|
||||
|
||||
- Starting any animated component from scratch
|
||||
- Setting up tokens, spring presets, or easing values
|
||||
- Implementing `prefers-reduced-motion` support
|
||||
- Debugging hydration mismatches from animation initial states
|
||||
- Evaluating whether an animation should exist at all
|
||||
|
||||
## Outputs
|
||||
|
||||
This skill produces:
|
||||
|
||||
- A shared `motionTokens` object (duration, easing, distance, scale)
|
||||
- A shared `springs` preset map (5 named configs)
|
||||
- A `shouldAnimate` boolean gate used by all components
|
||||
- Accessibility-compliant animation defaults via `useReducedMotion`
|
||||
- SSR-safe initial states with zero hydration warnings
|
||||
|
||||
## Principles
|
||||
|
||||
Motion must do at least one of the following or it must be removed:
|
||||
|
||||
- Guide attention
|
||||
- Communicate state
|
||||
- Preserve spatial continuity
|
||||
|
||||
Responsiveness always outranks smoothness. A 60 fps animation that causes
|
||||
input delay is worse than no animation.
|
||||
|
||||
## Rules
|
||||
|
||||
These are non-negotiable. They apply to every component in the system.
|
||||
|
||||
1. **Use `motion/react` only.** Never import from `framer-motion`. Never mix the two in the same tree.
|
||||
2. **`initial` must match server output.** If the server renders `opacity: 1`, the `initial` prop must also be `opacity: 1`. No exceptions.
|
||||
3. **Reduced motion overrides everything.** When `useReducedMotion()` returns `true` or `prefersReduced` is `true`, all transforms are disabled. Opacity-only fades at ≤ 0.2s are the only permitted fallback.
|
||||
4. **Never animate layout properties.** `width`, `height`, `top`, `left`, `margin`, `padding` are banned from `animate`. Use `transform` and `opacity` only.
|
||||
5. **All token values come from `motionTokens`.** Hardcoded durations and easings in component files are forbidden.
|
||||
6. **All spring configs come from the `springs` map.** Inline `stiffness`/`damping` values are forbidden.
|
||||
7. **`"use client"` is required** on every file that imports from `motion/react`.
|
||||
8. **Never read `window` or `navigator` at module level.** Always guard with `typeof window !== "undefined"`.
|
||||
|
||||
## Decision Guidance
|
||||
|
||||
### Choosing a duration
|
||||
|
||||
| Token | Use when |
|
||||
| --------- | -------------------------------------------- |
|
||||
| `instant` | Tooltip show/hide, focus ring, badge update |
|
||||
| `fast` | Button feedback, icon swap, chip toggle |
|
||||
| `normal` | Modal open, card expand, page element enter |
|
||||
| `slow` | Hero entrance, full-page transition |
|
||||
| `crawl` | Deliberate storytelling; use sparingly |
|
||||
|
||||
### Choosing a spring
|
||||
|
||||
| Preset | Use when |
|
||||
| --------- | ------------------------------------------ |
|
||||
| `snappy` | Default UI — buttons, chips, nav items |
|
||||
| `gentle` | Cards, modals, panels landing softly |
|
||||
| `bouncy` | Playful moments — empty states, onboarding |
|
||||
| `instant` | Tooltips, popovers, dropdowns |
|
||||
| `release` | Drag release — natural physics feel |
|
||||
|
||||
### When to disable animation entirely
|
||||
|
||||
Disable (set `shouldAnimate = false`) when:
|
||||
|
||||
- `prefersReduced` is `true`
|
||||
- `isLowEnd` is `true` and the animation is non-essential
|
||||
- The element is off-screen and will never enter the viewport
|
||||
- The animation is purely decorative with no UX purpose
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Token system
|
||||
|
||||
```ts
|
||||
// lib/motion-tokens.ts
|
||||
export const motionTokens = {
|
||||
duration: {
|
||||
instant: 0.08,
|
||||
fast: 0.18,
|
||||
normal: 0.35,
|
||||
slow: 0.6,
|
||||
crawl: 1.0,
|
||||
},
|
||||
easing: {
|
||||
smooth: [0.22, 1, 0.36, 1],
|
||||
sharp: [0.4, 0, 0.2, 1],
|
||||
bounce: [0.34, 1.56, 0.64, 1],
|
||||
linear: [0, 0, 1, 1],
|
||||
},
|
||||
distance: {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 48,
|
||||
},
|
||||
scale: {
|
||||
subtle: 0.98,
|
||||
press: 0.95,
|
||||
pop: 1.04,
|
||||
},
|
||||
}
|
||||
|
||||
export const springs = {
|
||||
snappy: { type: "spring", stiffness: 300, damping: 30 },
|
||||
gentle: { type: "spring", stiffness: 120, damping: 14 },
|
||||
bouncy: { type: "spring", stiffness: 400, damping: 10 },
|
||||
instant: { type: "spring", stiffness: 600, damping: 35 },
|
||||
release: { type: "spring", stiffness: 200, damping: 20, restDelta: 0.001 },
|
||||
}
|
||||
```
|
||||
|
||||
### Runtime flags
|
||||
|
||||
```ts
|
||||
// lib/motion-config.ts
|
||||
export const motionConfig = {
|
||||
isLowEnd:
|
||||
typeof navigator !== "undefined" &&
|
||||
navigator.hardwareConcurrency <= 4,
|
||||
|
||||
prefersReduced:
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
|
||||
get shouldAnimate() {
|
||||
return !this.prefersReduced
|
||||
},
|
||||
|
||||
get duration() {
|
||||
return this.isLowEnd || this.prefersReduced
|
||||
? motionTokens.duration.instant
|
||||
: motionTokens.duration.normal
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Accessibility
|
||||
|
||||
**Priority order (highest to lowest):**
|
||||
|
||||
1. `prefers-reduced-motion: reduce` — disables all transforms, limits opacity transitions to ≤ 0.2s
|
||||
2. Low-end device detection — reduces duration, removes non-essential animations
|
||||
3. Design preference — everything else
|
||||
|
||||
Motion must degrade gracefully. It must never disappear abruptly in a way
|
||||
that causes layout shift or confuses orientation.
|
||||
|
||||
```tsx
|
||||
// hooks/use-reduced-motion.tsx
|
||||
"use client"
|
||||
import { useReducedMotion } from "motion/react"
|
||||
|
||||
export function useSafeMotion(fullY: number = 16) {
|
||||
const reduce = useReducedMotion()
|
||||
return {
|
||||
initial: { opacity: 0, y: reduce ? 0 : fullY },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: reduce ? 0 : -fullY },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* globals.css */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.motion-safe-transition { transition: opacity 0.15s; }
|
||||
.motion-reduce-transform { transform: none !important; }
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- Tailwind -->
|
||||
<div class="motion-safe:animate-fade motion-reduce:opacity-100"></div>
|
||||
```
|
||||
|
||||
### SSR / hydration safety
|
||||
|
||||
**Rule: `initial` must always match what the server renders.**
|
||||
|
||||
```tsx
|
||||
// WRONG — server renders opacity:1 but initial says 0 → hydration mismatch
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
|
||||
|
||||
// CORRECT — use AnimatePresence or defer to client mount
|
||||
"use client"
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: mounted ? 0 : 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
/>
|
||||
```
|
||||
|
||||
## Code Examples
|
||||
|
||||
### End-to-end: tokens + springs + accessibility + SSR guard
|
||||
|
||||
```tsx
|
||||
// components/fade-in-card.tsx
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { motionTokens, springs } from "@/lib/motion-tokens"
|
||||
import { useSafeMotion } from "@/hooks/use-reduced-motion"
|
||||
import { motionConfig } from "@/lib/motion-config"
|
||||
|
||||
interface FadeInCardProps {
|
||||
children: React.ReactNode
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export function FadeInCard({ children, delay = 0 }: FadeInCardProps) {
|
||||
// SSR guard — initial must match server output (opacity: 1)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
// Accessibility — disables transform when reduced motion is preferred
|
||||
const safeMotion = useSafeMotion(motionTokens.distance.md)
|
||||
|
||||
// Device gate — skip animation on low-end hardware
|
||||
if (!motionConfig.shouldAnimate || !mounted) {
|
||||
return <div>{children}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={safeMotion.initial}
|
||||
animate={safeMotion.animate}
|
||||
exit={safeMotion.exit}
|
||||
transition={{
|
||||
...springs.gentle,
|
||||
delay,
|
||||
}}
|
||||
whileHover={{ scale: motionTokens.scale.pop }}
|
||||
whileTap={{ scale: motionTokens.scale.press }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints / Non-Goals
|
||||
|
||||
This skill does **not** cover:
|
||||
|
||||
- UI component patterns (button, modal, stagger) → see `motion-patterns`
|
||||
- Drag, gestures, SVG, text animations, custom hooks → see `motion-advanced`
|
||||
- CSS-only animations or Tailwind `animate-*` classes without `motion/react`
|
||||
- Third-party animation libraries (GSAP, anime.js, etc.)
|
||||
- Motion design decisions (when to animate, what to emphasize) — that is a design concern, not a code constraint
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-pattern | Rule violated | Fix |
|
||||
| --------------------------------------- | ------- | ------------------------------- |
|
||||
| `import { motion } from "framer-motion"` | Rule 1 | Use `motion/react` |
|
||||
| `initial={{ opacity: 0 }}` on SSR component | Rule 2 | Add mount guard |
|
||||
| Skipping `useReducedMotion` check | Rule 3 | Use `useSafeMotion` hook |
|
||||
| `animate={{ width: "100%" }}` | Rule 4 | Use `scaleX` transform instead |
|
||||
| `transition={{ duration: 0.4 }}` inline | Rule 5 | Use `motionTokens.duration.normal` |
|
||||
| `{ stiffness: 300, damping: 30 }` inline | Rule 6 | Use `springs.snappy` |
|
||||
| Missing `"use client"` directive | Rule 7 | Add to top of file |
|
||||
| `navigator.hardwareConcurrency` at module level | Rule 8 | Wrap in `typeof navigator !== "undefined"` |
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **`motion-patterns`** — consumes tokens and springs defined here to build button, modal, stagger, page transition, and scroll patterns. Does not redefine any values.
|
||||
- **`motion-advanced`** — consumes tokens and springs defined here for drag, SVG, text, and gesture patterns. Adds `useAnimate` sequences and custom hooks on top of this foundation.
|
||||
419
skills/motion-patterns/SKILL.md
Normal file
419
skills/motion-patterns/SKILL.md
Normal file
@ -0,0 +1,419 @@
|
||||
---
|
||||
name: motion-patterns
|
||||
description: Production-ready animation patterns for React / Next.js — button, modal, toast, stagger, page transitions, exit animations, scroll, and layout — built on motion-foundations tokens and springs.
|
||||
version: 1.0
|
||||
tags: [motion, animation, ui-patterns]
|
||||
category: frontend
|
||||
author: jeff
|
||||
---
|
||||
|
||||
# Motion Patterns
|
||||
|
||||
Copy-paste patterns for the most common UI animation needs.
|
||||
Every pattern here is built on `motion-foundations` tokens and springs.
|
||||
Do not define new duration or easing values here — import them.
|
||||
|
||||
## When to Activate
|
||||
|
||||
- Animating a button, card, modal, or toast notification
|
||||
- Building list entrances with stagger
|
||||
- Setting up page transitions in Next.js App Router
|
||||
- Adding entrance or exit animations to conditional content
|
||||
- Implementing scroll-reveal, scroll-linked progress, or sticky story sections
|
||||
- Building expanding cards, accordions, or shared-element transitions
|
||||
|
||||
## Outputs
|
||||
|
||||
This skill produces:
|
||||
|
||||
- Accessible, SSR-safe animation for all standard UI components
|
||||
- `AnimatePresence`-wrapped conditional renders with correct exit behavior
|
||||
- Page transition wrapper component for Next.js App Router
|
||||
- Scroll-reveal and scroll-linked patterns using `useScroll` + `useTransform`
|
||||
- Layout animation patterns (`layout`, `layoutId`) for expanding and crossfading elements
|
||||
|
||||
## Principles
|
||||
|
||||
- Every pattern imports from `motion-foundations`. No raw numbers.
|
||||
- Every conditional render is wrapped in `AnimatePresence` with a `key`.
|
||||
- Exit animations are always defined alongside enter animations — never as an afterthought.
|
||||
- `layout` is used only for small, isolated shifts. Large subtrees get explicit transforms.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Always wrap conditional renders in `AnimatePresence` with a `key`** on the direct child. Without a key, exit animations never fire.
|
||||
2. **Always define `exit` when defining `initial` + `animate`.** An animation without an exit is incomplete.
|
||||
3. **Use `mode="wait"` on page transitions.** Enter must not start until exit completes.
|
||||
4. **Never use `layout` on subtrees with more than ~5 children or deeply nested DOM.** Use explicit `x`/`y` transforms instead.
|
||||
5. **Stagger interval must stay between `0.05s` and `0.10s`.** Below feels mechanical; above feels sluggish.
|
||||
6. **Modals must always include:** focus trap, Escape-key close, scroll lock, `role="dialog"`, `aria-modal="true"`.
|
||||
7. **Scroll reveals use `viewport={{ once: true }}`.** Repeating on scroll-out is distracting, not informative.
|
||||
8. **All token values are imported from `motion-foundations`.** No inline numbers.
|
||||
|
||||
## Decision Guidance
|
||||
|
||||
### Choosing the right pattern
|
||||
|
||||
| Situation | Pattern |
|
||||
| ---------------------------------------- | ---------------------- |
|
||||
| Element appears / disappears | `AnimatePresence` |
|
||||
| List of items loading in sequence | Stagger variants |
|
||||
| Navigating between routes | Page transition wrapper|
|
||||
| Element changes size in place | `layout` prop |
|
||||
| Same element moves across page contexts | `layoutId` |
|
||||
| Element enters when scrolled into view | `whileInView` |
|
||||
| Value tied to scroll position | `useScroll` + `useTransform` |
|
||||
|
||||
### When to use `mode="wait"` vs `mode="sync"`
|
||||
|
||||
| Mode | Use when |
|
||||
| ------- | --------------------------------------- |
|
||||
| `wait` | Page transitions, content swaps (one at a time) |
|
||||
| `sync` | Stacked notifications, list items (overlap is fine) |
|
||||
| `popLayout` | Items removed from a reflow list |
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### AnimatePresence contract
|
||||
|
||||
Three things must always be true:
|
||||
|
||||
1. `AnimatePresence` wraps the conditional
|
||||
2. The direct child has a `key`
|
||||
3. The child has an `exit` prop
|
||||
|
||||
Miss any one of these and the exit animation silently fails.
|
||||
|
||||
### layout vs layoutId
|
||||
|
||||
- `layout` — animates the element's own size/position change in place
|
||||
- `layoutId` — links two separate elements, crossfading between them across renders
|
||||
|
||||
Use `layout="position"` on text inside an expanding container to prevent text reflow from animating.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Button feedback
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
import { motion } from "motion/react"
|
||||
import { springs, motionTokens } from "@/lib/motion-tokens"
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: motionTokens.scale.pop }}
|
||||
whileTap={{ scale: motionTokens.scale.press }}
|
||||
transition={springs.snappy}
|
||||
/>
|
||||
```
|
||||
|
||||
### Stagger list
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
import { motion } from "motion/react"
|
||||
import { motionTokens, springs } from "@/lib/motion-tokens"
|
||||
|
||||
const container = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.08, // within the 0.05–0.10 rule
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: motionTokens.distance.md },
|
||||
visible: { opacity: 1, y: 0, transition: springs.gentle },
|
||||
}
|
||||
|
||||
<motion.ul variants={container} initial="hidden" animate="visible">
|
||||
{items.map((i) => (
|
||||
<motion.li key={i.id} variants={item} />
|
||||
))}
|
||||
</motion.ul>
|
||||
```
|
||||
|
||||
### Modal
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { springs } from "@/lib/motion-tokens"
|
||||
|
||||
// Wrap at the call site:
|
||||
// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>
|
||||
|
||||
export function Modal({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel — accessibility requirements: focus trap, Escape close,
|
||||
scroll lock, role="dialog", aria-modal="true" */}
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-x-4 top-1/2 -translate-y-1/2 rounded-xl bg-white p-6"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 8 }}
|
||||
transition={springs.gentle}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Toast stack
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { springs } from "@/lib/motion-tokens"
|
||||
|
||||
<AnimatePresence mode="sync">
|
||||
{toasts.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 48, scale: 0.9 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 48, scale: 0.9 }}
|
||||
transition={springs.snappy}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
```
|
||||
|
||||
### Page transition (Next.js App Router)
|
||||
|
||||
```tsx
|
||||
// components/page-transition.tsx
|
||||
"use client"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { motionTokens } from "@/lib/motion-tokens"
|
||||
|
||||
const variants = {
|
||||
initial: { opacity: 0, y: motionTokens.distance.sm },
|
||||
enter: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -motionTokens.distance.sm },
|
||||
}
|
||||
|
||||
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={pathname}
|
||||
variants={variants}
|
||||
initial="initial"
|
||||
animate="enter"
|
||||
exit="exit"
|
||||
transition={{
|
||||
duration: motionTokens.duration.normal,
|
||||
ease: motionTokens.easing.smooth,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Scroll reveal
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
import { motion } from "motion/react"
|
||||
import { motionTokens, springs } from "@/lib/motion-tokens"
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: motionTokens.distance.lg }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }} // once: true — rule 7
|
||||
transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
|
||||
/>
|
||||
```
|
||||
|
||||
### Scroll progress bar
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
import { motion, useScroll } from "motion/react"
|
||||
|
||||
export function ScrollProgress() {
|
||||
const { scrollYProgress } = useScroll()
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 h-1 bg-indigo-500 origin-left w-full"
|
||||
style={{ scaleX: scrollYProgress }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Expanding card
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { springs, motionTokens } from "@/lib/motion-tokens"
|
||||
|
||||
export function ExpandingCard({ title, body }: { title: string; body: string }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<motion.div layout onClick={() => setExpanded(!expanded)} className="cursor-pointer">
|
||||
{/* layout="position" prevents text reflow from animating */}
|
||||
<motion.h2 layout="position" className="font-semibold">
|
||||
{title}
|
||||
</motion.h2>
|
||||
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.p
|
||||
key="body"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: motionTokens.duration.fast }}
|
||||
>
|
||||
{body}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Shared-element crossfade
|
||||
|
||||
```tsx
|
||||
// Source context
|
||||
<motion.img layoutId="hero-image" src={src} className="w-16 h-16 rounded" />
|
||||
|
||||
// Destination context (same layoutId — motion handles the transition)
|
||||
<motion.img layoutId="hero-image" src={src} className="w-full rounded-xl" />
|
||||
```
|
||||
|
||||
### Accordion
|
||||
|
||||
```tsx
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ opacity: open ? 1 : 0, scaleY: open ? 1 : 0 }}
|
||||
style={{ transformOrigin: "top", overflow: "hidden" }}
|
||||
transition={{
|
||||
duration: motionTokens.duration.normal,
|
||||
ease: motionTokens.easing.smooth,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
## End-to-End Example
|
||||
|
||||
A staggered list that enters on mount, handles conditional presence, and
|
||||
respects reduced motion — combining tokens, springs, AnimatePresence, and
|
||||
the accessibility hook from `motion-foundations`:
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { motionTokens, springs } from "@/lib/motion-tokens"
|
||||
import { useSafeMotion } from "@/hooks/use-reduced-motion"
|
||||
|
||||
const containerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
|
||||
},
|
||||
}
|
||||
|
||||
function ListItem({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||
const safe = useSafeMotion(motionTokens.distance.sm)
|
||||
return (
|
||||
<motion.li
|
||||
variants={{
|
||||
hidden: safe.initial,
|
||||
visible: safe.animate,
|
||||
}}
|
||||
exit={safe.exit}
|
||||
transition={springs.gentle}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-white shadow-sm"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<button onClick={onRemove}>Remove</button>
|
||||
</motion.li>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnimatedList({ items, onRemove }: {
|
||||
items: { id: string; label: string }[]
|
||||
onRemove: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<motion.ul
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="space-y-2"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{items.map((item) => (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
onRemove={() => onRemove(item.id)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints / Non-Goals
|
||||
|
||||
This skill does **not** cover:
|
||||
|
||||
- Token and spring definitions → see `motion-foundations`
|
||||
- Drag interactions, swipe gestures, reorderable lists → see `motion-advanced`
|
||||
- Text animations (word/character reveal, counters) → see `motion-advanced`
|
||||
- SVG path drawing or morphing → see `motion-advanced`
|
||||
- Custom animation hooks → see `motion-advanced`
|
||||
- CSS-only transitions not using `motion/react`
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-pattern | Rule violated | Fix |
|
||||
| -------------------------------------------- | ------- | ------------------------------------------ |
|
||||
| `AnimatePresence` child missing `key` | Rule 1 | Add stable `key` to the direct child |
|
||||
| `initial` + `animate` without `exit` | Rule 2 | Always define all three together |
|
||||
| Page transition without `mode="wait"` | Rule 3 | Add `mode="wait"` to `AnimatePresence` |
|
||||
| `layout` on a 50-item list | Rule 4 | Use `mode="popLayout"` or explicit transforms |
|
||||
| `staggerChildren: 0.2` on a 10-item list | Rule 5 | Cap at `0.08–0.10` |
|
||||
| Modal without focus trap | Rule 6 | Add `focus-trap-react` or Radix Dialog |
|
||||
| `whileInView` without `viewport={{ once: true }}` | Rule 7 | Repeating entrances distract, not inform |
|
||||
| `transition={{ duration: 0.3 }}` inline | Rule 8 | Use `motionTokens.duration.normal` |
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **`motion-foundations`** — defines all tokens, springs, the `useSafeMotion` hook, and SSR guards that every pattern here imports. Must be set up first.
|
||||
- **`motion-advanced`** — extends these patterns with drag, gestures, SVG, text, custom hooks, and imperative sequencing. Does not redefine any patterns from this skill.
|
||||
Loading…
x
Reference in New Issue
Block a user