diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 106ace1c..5bbc44bc 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3df4b3c2..fb77231f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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" diff --git a/AGENTS.md b/AGENTS.md index a8daa5c1..03a9b322 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/README.md b/README.md index c385a2f3..4f370dc2 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README.zh-CN.md b/README.zh-CN.md index c420c2f7..e31822dd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -160,7 +160,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**完成!** 你现在可以使用 58 个代理、217 个技能和 74 个命令。 +**完成!** 你现在可以使用 58 个代理、220 个技能和 74 个命令。 ### multi-* 命令需要额外配置 diff --git a/docs/zh-CN/AGENTS.md b/docs/zh-CN/AGENTS.md index 25d7494e..a4fac6e9 100644 --- a/docs/zh-CN/AGENTS.md +++ b/docs/zh-CN/AGENTS.md @@ -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/ — 始终遵循的指导方针(通用 + 每种语言) diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 1d4c1adb..437f2f95 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -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 条指令 | diff --git a/skills/motion-advanced/SKILL.md b/skills/motion-advanced/SKILL.md new file mode 100644 index 00000000..3b4116b7 --- /dev/null +++ b/skills/motion-advanced/SKILL.md @@ -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
...
+``` + +## Code Examples + +### Draggable card + +```tsx +"use client" +import { motion } from "motion/react" +import { springs, motionTokens } from "@/lib/motion-tokens" + + +``` + +### 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 ( + { + // 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 ( + + {items.map((item) => ( + + {item.label} + + ))} + + ) +} +``` + +### Swipe detection + +```tsx +"use client" +import { motion } from "motion/react" + +const OFFSET_THRESHOLD = 50 +const VELOCITY_THRESHOLD = 300 + + { + 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>() + 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 ( + + {text.split(" ").map((word, i) => ( + + {word} + + ))} + + ) +} +``` + +### 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(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 +} +``` + +### SVG path draw-on + +```tsx +"use client" +import { motion } from "motion/react" +import { motionTokens } from "@/lib/motion-tokens" + + +``` + +### 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 ( + + + + + ) +} +``` + +### 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() + +``` + +### 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 ( + + ) +} +``` + +### 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 ( +
+ +
+ ) + } + + ) +} +``` + +### 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 ( + + + {loading ? ( + + … + + ) : ( + + {label} + + )} + + + ) +} +``` + +### 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 +} +``` + +## 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 ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Sheet — drag-to-dismiss */} + { + if (info.offset.y > 120 || info.velocity.y > 500) onClose() + }} + initial={safe.initial} + animate={safe.animate} + exit={safe.exit} + transition={springs.gentle} + > + {loading ? ( +
+ + + +
+ ) : children} +
+ + )} +
+ ) +} +``` + +## 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. diff --git a/skills/motion-foundations/SKILL.md b/skills/motion-foundations/SKILL.md new file mode 100644 index 00000000..94521f1b --- /dev/null +++ b/skills/motion-foundations/SKILL.md @@ -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 + +
+``` + +### SSR / hydration safety + +**Rule: `initial` must always match what the server renders.** + +```tsx +// WRONG — server renders opacity:1 but initial says 0 → hydration mismatch + + +// CORRECT — use AnimatePresence or defer to client mount +"use client" +const [mounted, setMounted] = useState(false) +useEffect(() => setMounted(true), []) + + +``` + +## 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
{children}
+ } + + return ( + + {children} + + ) +} +``` + +## 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. diff --git a/skills/motion-patterns/SKILL.md b/skills/motion-patterns/SKILL.md new file mode 100644 index 00000000..80ff2dd9 --- /dev/null +++ b/skills/motion-patterns/SKILL.md @@ -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" + + +``` + +### 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 }, +} + + + {items.map((i) => ( + + ))} + +``` + +### Modal + +```tsx +"use client" +import { motion, AnimatePresence } from "motion/react" +import { springs } from "@/lib/motion-tokens" + +// Wrap at the call site: +// {isOpen && } + +export function Modal({ onClose }: { onClose: () => void }) { + return ( + <> + {/* Overlay */} + + + {/* Panel — accessibility requirements: focus trap, Escape close, + scroll lock, role="dialog", aria-modal="true" */} + + + ) +} +``` + +### Toast stack + +```tsx +"use client" +import { motion, AnimatePresence } from "motion/react" +import { springs } from "@/lib/motion-tokens" + + + {toasts.map((t) => ( + + ))} + +``` + +### 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 ( + + + {children} + + + ) +} +``` + +### Scroll reveal + +```tsx +"use client" +import { motion } from "motion/react" +import { motionTokens, springs } from "@/lib/motion-tokens" + + +``` + +### Scroll progress bar + +```tsx +"use client" +import { motion, useScroll } from "motion/react" + +export function ScrollProgress() { + const { scrollYProgress } = useScroll() + return ( + + ) +} +``` + +### 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 ( + setExpanded(!expanded)} className="cursor-pointer"> + {/* layout="position" prevents text reflow from animating */} + + {title} + + + + {expanded && ( + + {body} + + )} + + + ) +} +``` + +### Shared-element crossfade + +```tsx +// Source context + + +// Destination context (same layoutId — motion handles the transition) + +``` + +### Accordion + +```tsx + + {children} + +``` + +## 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 ( + + {label} + + + ) +} + +export function AnimatedList({ items, onRemove }: { + items: { id: string; label: string }[] + onRemove: (id: string) => void +}) { + return ( + + + {items.map((item) => ( + onRemove(item.id)} + /> + ))} + + + ) +} +``` + +## 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.