diff --git a/skills/motion_ui/skill.md b/skills/motion_ui/skill.md index e7fe30b6..a5462dd9 100644 --- a/skills/motion_ui/skill.md +++ b/skills/motion_ui/skill.md @@ -1,10 +1,9 @@ -````md --- name: motion-ui description: "Production-ready UI motion system for React/Next.js. Use when implementing animations, transitions, or motion patterns." --- -# Motion System v4.1 +# Motion System v4.2 Production-ready UI motion system for React / Next.js. @@ -12,75 +11,98 @@ Focused on **performance, accessibility, and usability** — not decoration. **by Jatan** ---- - ## When to Use Use this motion system when motion: -- Guides attention (onboarding, primary actions) -- Communicates state (loading, success, error, transitions) -- Preserves spatial continuity (navigation, layout changes) +* Guides attention (e.g., onboarding, key actions) +* Communicates state (loading, success, error, transitions) +* Preserves spatial continuity (layout changes, navigation) ### Appropriate Scenarios -- Interactive UI (buttons, modals, menus) -- State transitions (open/close, loading states) -- Navigation transitions and shared elements +* Interactive components (buttons, modals, menus) +* State transitions (loading → loaded, open → closed) +* Navigation and layout continuity (shared elements, crossfade) ### Considerations -- Accessibility must be preserved (reduced motion support) -- Low-end device performance must be respected -- Prefer responsiveness over visual smoothness +* **Accessibility**: Always support reduced motion +* **Device adaptation**: Adjust for low-end devices +* **Performance trade-offs**: Prefer responsiveness over visual smoothness -### Avoid Motion When +### Avoid Using Motion When -- It is purely decorative -- It reduces clarity or usability -- It impacts performance +* It is purely decorative +* It reduces usability or clarity +* It impacts performance negatively --- -## Core Principle +## How It Works + +### Core Principle Motion must: -- Guide attention -- Communicate state -- Preserve spatial continuity +* Guide attention +* Communicate state +* Preserve spatial continuity If it does none → remove it. --- -## Installation +### Installation ```bash npm install motion -```` +``` --- -## Versions +### Version -* `motion/react` → default -* `framer-motion` → legacy (do not mix) +* `motion/react` → default (v11+, package: `motion`) +* `framer-motion` → legacy (v10 and below, package: `framer-motion`) ---- +**Do not mix.** Mixing causes conflicting internal schedulers and broken `AnimatePresence` contexts — components from one package will not coordinate exit animations with components from the other. -## Motion Tokens +To check which version your project uses: + +```bash +cat package.json | grep -E '"motion"|"framer-motion"' +``` + +Always import from one source consistently: ```ts +// ✅ Correct (modern) +import { motion, AnimatePresence } from "motion/react" + +// ✅ Correct (legacy) +import { motion, AnimatePresence } from "framer-motion" + +// ❌ Never mix both in the same project +``` + +--- + +### Motion Tokens + +```ts +// motionTokens.ts export const motionTokens = { duration: { fast: 0.18, normal: 0.35, slow: 0.6 }, + // Use these as the `ease` value inside a `transition` object: + // transition={{ duration: motionTokens.duration.normal, ease: motionTokens.easing.smooth }} easing: { - smooth: [0.22, 1, 0.36, 1], - sharp: [0.4, 0, 0.2, 1] + smooth: [0.22, 1, 0.36, 1] as [number, number, number, number], + sharp: [0.4, 0, 0.2, 1] as [number, number, number, number] }, distance: { sm: 8, @@ -90,48 +112,67 @@ export const motionTokens = { } ``` +Usage example: + +```tsx +import { motionTokens } from "@/lib/motionTokens" + + +``` + --- -## Performance Rules +### Performance Rules -### Safe Properties +**Safe** * transform * opacity -### Avoid +**Avoid** -* width -* height -* top -* left +* width / height +* top / left Rule: responsiveness > smoothness --- -## Device Adaptation +### Device Adaptation + +The heuristic combines CPU core count **and** available memory for a more reliable signal. `deviceMemory` is available on Chrome/Android; the fallback covers Safari and Firefox. ```ts const isLowEnd = - typeof navigator !== "undefined" && - navigator.hardwareConcurrency <= 4 + typeof navigator !== "undefined" && ( + // Low memory (Chrome/Android only; undefined elsewhere → treat as capable) + (navigator.deviceMemory !== undefined && navigator.deviceMemory <= 2) || + // Few cores AND no memory API (covers Safari/Firefox on weak hardware) + (navigator.deviceMemory === undefined && navigator.hardwareConcurrency <= 4) + ) const duration = isLowEnd ? 0.2 : 0.4 ``` --- -## Accessibility +### Accessibility -### Reduced Motion (React) +#### JS (useReducedMotion) ```tsx import { motion, useReducedMotion } from "motion/react" -const reduce = useReducedMotion() +export function FadeIn() { + const reduce = useReducedMotion() -export function Example() { return ( @@ -163,79 +204,102 @@ export function Example() { --- -## Core Patterns +### Architecture & Patterns -* hover → whileHover -* tap → whileTap -* in-view → whileInView -* scroll → useScroll -* conditional → AnimatePresence -* small layout → layout -* large layout → avoid -* complex → useAnimate +#### Core Patterns ---- +| Scenario | Pattern | +|---|---| +| Hover feedback | `whileHover` | +| Tap / press feedback | `whileTap` | +| Reveal on scroll | `whileInView` | +| Scroll-linked value | `useScroll` + `useTransform` | +| Conditional mount/unmount | `AnimatePresence` | +| Small layout shifts (single element, < ~300px change) | `layout` prop | +| Large layout shifts or full-page reflows | Avoid `layout`; use CSS transitions or page-level routing instead | +| Complex, imperative sequences | `useAnimate` | -## Layout System +> **Why avoid `layout` on large containers?** Framer's layout animation uses `transform` to reconcile positions, but on elements that span the full viewport or trigger deep reflow, the measurement cost causes visible jank and CLS. Prefer CSS Grid/Flexbox transitions or coordinate with `layoutId` on specific child elements only. -* layoutId → shared transitions -* AnimatePresence → mount/unmount transitions +#### Layout & Transitions ---- +* Shared element transitions → `layoutId` (must be unique per mounted instance) +* Enter / exit transitions → `AnimatePresence` (see `mode` guidance below) -## Advanced Patterns +#### AnimatePresence `mode` -* Parallax scrolling -* Scroll storytelling sections -* 3D pointer tilt -* Crossfade transitions -* Clip-path reveals -* Skeleton loading loops -* Micro-interactions -* Spring physics motion +Always specify `mode` explicitly — the default (`"sync"`) runs enter and exit simultaneously, which causes visual overlap in most UI patterns. ---- - -## Modal System (Production Safe) +| `mode` | When to use | +|---|---| +| `"wait"` | Exit completes before enter starts. Use for **modals, toasts, page transitions**. | +| `"sync"` (default) | Enter and exit overlap. Use only when overlap is intentional (e.g., crossfade carousels). | +| `"popLayout"` | Exiting element is popped out of flow immediately; remaining items animate to fill. Use for **lists, tabs, dismissible cards**. | ```tsx -import { useEffect, useRef, useState } from "react" +// Modal — always use "wait" + + {open && } + + +// Dismissible list item — use "popLayout" + + {items.map(item => )} + +``` + +--- + +### Advanced Patterns (Concepts) + +* Parallax (scroll-linked transforms) +* Scroll storytelling (sticky sections) +* 3D tilt (pointer-based transforms) +* Crossfade (shared `layoutId`) +* Progressive reveal (clip-path) +* Skeleton loading (looped opacity) +* Micro-interactions (hover/tap feedback) +* Spring system (physics-based motion) + +--- + +### Modal Essentials + +* Focus trap +* Escape close +* Scroll lock +* ARIA roles +* Use `AnimatePresence mode="wait"` so exit animation completes before the next modal enters + +#### Full Example + +```tsx +import React, { useEffect, useRef, useState } from "react" import { motion, AnimatePresence } from "motion/react" -type ModalProps = { - open: boolean - onClose: () => void -} - -function useFocusTrap(ref: React.RefObject, active: boolean) { +function useFocusTrap(ref: React.RefObject, active: boolean) { useEffect(() => { if (!active || !ref.current) return - const el = ref.current - const focusable = el.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) - const first = focusable[0] - const last = focusable[focusable.length - 1] + const last = focusable[focusable.length - 1] - if (first) first.focus() - - const handleKey = (e: KeyboardEvent) => { + function handleKey(e: KeyboardEvent) { if (e.key !== "Tab") return - if (!first || !last) return - if (e.shiftKey && document.activeElement === first) { e.preventDefault() - last.focus() + last?.focus() } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault() - first.focus() + first?.focus() } } el.addEventListener("keydown", handleKey) + first?.focus() return () => el.removeEventListener("keydown", handleKey) }, [active, ref]) } @@ -243,63 +307,223 @@ function useFocusTrap(ref: React.RefObject, active: boolean) { function useScrollLock(active: boolean) { useEffect(() => { if (!active) return - const prev = document.body.style.overflow document.body.style.overflow = "hidden" - - return () => { - document.body.style.overflow = prev - } + return () => { document.body.style.overflow = prev } }, [active]) } -export function Modal({ open, onClose }: ModalProps) { +function Modal({ open, closeModal }: { open: boolean; closeModal: () => void }) { const ref = useRef(null) useFocusTrap(ref, open) useScrollLock(open) useEffect(() => { - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose() + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") closeModal() } - - if (open) window.addEventListener("keydown", onKeyDown) - - return () => window.removeEventListener("keydown", onKeyDown) - }, [open, onClose]) + if (open) window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [open, closeModal]) return ( - + // mode="wait" ensures exit animation finishes before any new modal enters + {open && ( - + )} ) } + +export function Example() { + const [open, setOpen] = useState(false) + + return ( + <> + + setOpen(false)} /> + + ) +} +``` + +--- + +### SSR Safety + +* Match initial states between server and client renders +* Avoid implicit animation origins (always set `initial` explicitly) +* Wrap motion components in `"use client"` in Next.js App Router + +--- + +### Debugging + +Check: + +* Wrong import (mixing `motion/react` and `framer-motion`) +* Missing `"use client"` directive in Next.js App Router +* Missing `key` prop on `AnimatePresence` children +* Hydration mismatch (initial state differs between SSR and client) +* `layout` prop misuse on large containers causing reflow jank +* State-driven animation not triggering (check dependency arrays) + +--- + +### QA + +* No CLS +* Keyboard works +* Focus trapped in modals +* ARIA roles correct (`role="dialog"`, `aria-modal="true"`) +* Reduced motion respected (`useReducedMotion` + CSS media query) +* No hydration warnings in Next.js +* Animations stop cleanly on unmount (no memory leaks) +* `AnimatePresence mode` set explicitly on all usage sites + +--- + +### Anti-Patterns + +* Animating layout properties (`width`, `height`, `top`, `left`) +* Infinite animations without purpose (always ask: what state does this communicate?) +* Over-staggering lists (keep `staggerChildren` ≤ 0.1s; beyond that it feels slow) +* Ignoring reduced motion preferences +* Using `layout` on large or full-viewport containers +* Omitting `mode` on `AnimatePresence` (default `"sync"` causes visual overlap) +* Using motion purely for decoration + +--- + +### Philosophy + +Motion is interaction design. + +--- + +### Final Rule + +> If motion does not improve UX → remove it. + +--- + +## Examples + +### Button Interaction + +```tsx +import { motion } from "motion/react" + +export function Button() { + return ( + + Click me + + ) +} +``` + +--- + +### Reduced Motion Example + +```tsx +import { motion, useReducedMotion } from "motion/react" + +export function FadeIn() { + const reduce = useReducedMotion() + + return ( + + ) +} +``` + +--- + +### Stagger List + +```tsx +import { motion } from "motion/react" + +const container = { + hidden: {}, + visible: { + transition: { staggerChildren: 0.08 } // keep ≤ 0.1s to avoid sluggishness + } +} + +const item = { + hidden: { opacity: 0, y: 10 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.22, 1, 0.36, 1] } } +} + +export function List() { + return ( + + {[1, 2, 3].map(i => ( + Item {i} + ))} + + ) +} +``` + +--- + +### Modal with AnimatePresence + +```tsx +import { motion, AnimatePresence } from "motion/react" + +export function Modal({ open }: { open: boolean }) { + return ( + + {open && ( + + )} + + ) +} ``` --- -## Scroll Parallax +### Scroll Parallax ```tsx import { useScroll, useTransform, motion } from "motion/react" @@ -314,7 +538,7 @@ export function Parallax() { --- -## Skeleton Loading +### Skeleton Loading ```tsx import { motion } from "motion/react" @@ -322,9 +546,13 @@ import { motion } from "motion/react" export function Skeleton() { return ( ) } @@ -332,81 +560,15 @@ export function Skeleton() { --- -## Shared Layout +### Shared Layout (Crossfade) ```tsx import { motion } from "motion/react" +// layoutId must be unique per mounted instance. +// If multiple instances can exist simultaneously, append a unique id: +// layoutId={`shared-${item.id}`} export function Shared() { return } ``` - ---- - -## Stagger List - -```tsx -import { motion } from "motion/react" - -const container = { - hidden: {}, - visible: { - transition: { staggerChildren: 0.08 } - } -} - -const item = { - hidden: { opacity: 0, y: 10 }, - visible: { opacity: 1, y: 0 } -} - -export function List() { - return ( - - {[1, 2, 3].map(i => ( - - Item {i} - - ))} - - ) -} -``` - ---- - -## Debug Checklist - -* correct import (`motion/react`) -* `"use client"` in Next.js -* no missing keys -* no layout shift (CLS) -* no hydration mismatch -* reduced motion works -* keyboard navigation works - ---- - -## Anti-Patterns - -* animating layout (width/height) -* decorative motion -* infinite motion without purpose -* ignoring reduced motion -* over-staggering lists - ---- - -## Philosophy - -Motion is interaction design. - ---- - -## Final Rule - -> If motion does not improve UX → remove it. - -``` -```