mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-15 03:04:43 +08:00
Fix copied example issues from the adopted #1780 motion skills: live reduced-motion config, tokenized distances/easing/springs, valid shimmer skeleton JSX, and visibility cleanup.
300 lines
9.4 KiB
Markdown
300 lines
9.4 KiB
Markdown
---
|
|
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()` 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 (make `shouldAnimate()` return `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() {
|
|
return (
|
|
typeof navigator !== "undefined" &&
|
|
navigator.hardwareConcurrency <= 4
|
|
)
|
|
},
|
|
|
|
prefersReduced() {
|
|
return (
|
|
typeof window !== "undefined" &&
|
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
)
|
|
},
|
|
|
|
shouldAnimate({ essential = false } = {}) {
|
|
if (this.prefersReduced()) return false
|
|
if (!essential && this.isLowEnd()) return false
|
|
return true
|
|
},
|
|
|
|
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.
|