Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/motion-foundations/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-17 | ✗→✓ | ▲ Improved | — | — |
| case-02 | ✗→✓ | ▲ Improved | — | — |
| case-03 | ✗→✓ | ▲ Improved | — | — |
| case-06 | ✗→✓ | ▲ Improved | — | — |
| case-15 | ✗→✓ | ▲ Improved | — | — |
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.
prefers-reduced-motion supportThis skill produces:
motionTokens object (duration, easing, distance, scale)springs preset map (5 named configs)shouldAnimate() gate used by all componentsuseReducedMotionMotion must do at least one of the following or it must be removed:
Responsiveness always outranks smoothness. A 60 fps animation that causes input delay is worse than no animation.
These are non-negotiable. They apply to every component in the system.
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.useReducedMotion() returns true or prefersReduced is true, all transforms are disabled. Opacity-only fades at ≤ 0.2s are the only permitted fallback.width, height, top, left, margin, padding are banned from animate. Use transform and opacity only.motionTokens. Hardcoded durations and easings in component files are forbidden.springs map. Inline stiffness/damping values are forbidden."use client" is required on every file that imports from motion/react.window or navigator at module level. Always guard with typeof window !== "undefined".| 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 |
| 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 |
Disable (make shouldAnimate() return false) when:
prefersReduced is trueisLowEnd is true and the animation is non-essentialts// 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 }, }
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 }, }
Priority order (highest to lowest):
prefers-reduced-motion: reduce — disables all transforms, limits opacity transitions to ≤ 0.2sMotion 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>
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 }} />
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> ) }
This skill does not cover:
motion-patternsmotion-advancedanimate-* classes without motion/react| 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" |
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.| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-17 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted, and 21 counted toward the lift figure. The other 1 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +45 percentage points is the difference between those two pass rates over the 21 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.