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 +