From 5178539fb0c131a79fbea8f7aa2111ecee24acfa Mon Sep 17 00:00:00 2001 From: Jeff Date: Tue, 12 May 2026 10:18:51 +0530 Subject: [PATCH] Create SKILL.md for Motion Foundations Add Motion Foundations skill documentation with guidelines for animation, accessibility, and SSR safety. --- skills/motion-foundations/SKILL.md | 291 +++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 skills/motion-foundations/SKILL.md 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.