mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-14 02:10:07 +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",
|
"name": "ecc",
|
||||||
"source": "./",
|
"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",
|
"version": "2.0.0-rc.1",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Affaan Mustafa",
|
"name": "Affaan Mustafa",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ecc",
|
"name": "ecc",
|
||||||
"version": "2.0.0-rc.1",
|
"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": {
|
"author": {
|
||||||
"name": "Affaan Mustafa",
|
"name": "Affaan Mustafa",
|
||||||
"url": "https://x.com/affaanmustafa"
|
"url": "https://x.com/affaanmustafa"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Everything Claude Code (ECC) — Agent Instructions
|
# 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
|
**Version:** 2.0.0-rc.1
|
||||||
|
|
||||||
@ -148,7 +148,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
|
|||||||
|
|
||||||
```
|
```
|
||||||
agents/ — 58 specialized subagents
|
agents/ — 58 specialized subagents
|
||||||
skills/ — 217 workflow skills and domain knowledge
|
skills/ — 220 workflow skills and domain knowledge
|
||||||
commands/ — 74 slash commands
|
commands/ — 74 slash commands
|
||||||
hooks/ — Trigger-based automations
|
hooks/ — Trigger-based automations
|
||||||
rules/ — Always-follow guidelines (common + per-language)
|
rules/ — Always-follow guidelines (common + per-language)
|
||||||
|
|||||||
@ -358,7 +358,7 @@ If you stacked methods, clean up in this order:
|
|||||||
/plugin list ecc@ecc
|
/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
|
### 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** |
|
| Agents | PASS: 58 agents | PASS: 12 agents | **Claude Code leads** |
|
||||||
| Commands | PASS: 74 commands | PASS: 35 commands | **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!** |
|
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
|
||||||
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
|
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
|
||||||
| MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** |
|
| 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 |
|
| **Agents** | 58 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |
|
||||||
| **Commands** | 74 | Shared | Instruction-based | 35 |
|
| **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 Events** | 8 types | 15 types | None yet | 11 types |
|
||||||
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks |
|
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks |
|
||||||
| **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions |
|
| **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
|
/plugin list ecc@ecc
|
||||||
```
|
```
|
||||||
|
|
||||||
**完成!** 你现在可以使用 58 个代理、217 个技能和 74 个命令。
|
**完成!** 你现在可以使用 58 个代理、220 个技能和 74 个命令。
|
||||||
|
|
||||||
### multi-* 命令需要额外配置
|
### multi-* 命令需要额外配置
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Everything Claude Code (ECC) — 智能体指令
|
# Everything Claude Code (ECC) — 智能体指令
|
||||||
|
|
||||||
这是一个**生产就绪的 AI 编码插件**,提供 58 个专业代理、217 项技能、74 条命令以及自动化钩子工作流,用于软件开发。
|
这是一个**生产就绪的 AI 编码插件**,提供 58 个专业代理、220 项技能、74 条命令以及自动化钩子工作流,用于软件开发。
|
||||||
|
|
||||||
**版本:** 2.0.0-rc.1
|
**版本:** 2.0.0-rc.1
|
||||||
|
|
||||||
@ -147,7 +147,7 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
agents/ — 58 个专业子代理
|
agents/ — 58 个专业子代理
|
||||||
skills/ — 217 个工作流技能和领域知识
|
skills/ — 220 个工作流技能和领域知识
|
||||||
commands/ — 74 个斜杠命令
|
commands/ — 74 个斜杠命令
|
||||||
hooks/ — 基于触发的自动化
|
hooks/ — 基于触发的自动化
|
||||||
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
||||||
|
|||||||
@ -224,7 +224,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
|||||||
/plugin list ecc@ecc
|
/plugin list ecc@ecc
|
||||||
```
|
```
|
||||||
|
|
||||||
**搞定!** 你现在可以使用 58 个智能体、217 项技能和 74 个命令了。
|
**搞定!** 你现在可以使用 58 个智能体、220 项技能和 74 个命令了。
|
||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
@ -1134,7 +1134,7 @@ opencode
|
|||||||
|---------|-------------|----------|--------|
|
|---------|-------------|----------|--------|
|
||||||
| 智能体 | PASS: 58 个 | PASS: 12 个 | **Claude Code 领先** |
|
| 智能体 | PASS: 58 个 | PASS: 12 个 | **Claude Code 领先** |
|
||||||
| 命令 | PASS: 74 个 | PASS: 35 个 | **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: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
|
||||||
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
||||||
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
|
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
|
||||||
@ -1242,7 +1242,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
|
|||||||
|---------|------------|------------|-----------|----------|
|
|---------|------------|------------|-----------|----------|
|
||||||
| **智能体** | 58 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
| **智能体** | 58 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||||
| **命令** | 74 | 共享 | 基于指令 | 35 |
|
| **命令** | 74 | 共享 | 基于指令 | 35 |
|
||||||
| **技能** | 217 | 共享 | 10 (原生格式) | 37 |
|
| **技能** | 220 | 共享 | 10 (原生格式) | 37 |
|
||||||
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
|
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
|
||||||
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
|
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
|
||||||
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
|
| **规则** | 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