Motion tokens, spring presets, performance rules, device adaptation, accessibility enforcement, and SSR safety for React / Next.js using motion/react.…
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.
Use motion/react only. Never import from framer-motion. Never mix the two in the same tree.
initial must match server output. If the server renders opacity: 1, the initial prop must also be opacity: 1. No exceptions.
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.
Never animate layout properties. width, height, top, left, margin, padding are banned from animate. Use transform and opacity only.
All token values come from motionTokens. Hardcoded durations and easings in component files are forbidden.
All spring configs come from the springs map. Inline stiffness/damping values are forbidden.
"use client" is required on every file that imports from motion/react.
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
// 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
// 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):
prefers-reduced-motion: reduce — disables all transforms, limits opacity transitions to ≤ 0.2s
Low-end device detection — reduces duration, removes non-essential animations
Design preference — everything else
Motion must degrade gracefully. It must never disappear abruptly in a way
that causes layout shift or confuses orientation.
// 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 },
}
}
/* globals.css */
@media (prefers-reduced-motion: reduce) {
.motion-safe-transition { transition: opacity 0.15s; }
.motion-reduce-transform { transform: none !important; }
}
<!-- Tailwind -->
<div class="motion-safe:animate-fade motion-reduce:opacity-100"></div>
SSR / hydration safety
Rule: initial must always match what the server renders.
// 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
// 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.don't have the plugin yet? install it then click "run inline in claude" again.
by @sergiodxa