| name | motion-ui |
| description | Production-ready React/Next.js motion system (Framer Motion / Motion for React) covering tokens, AnimatePresence, reduced motion, and SSR safety. Use when adding or reviewing animation in frontend/ — a new motion.* component, an AnimatePresence enter/exit, a scroll-linked transform, or a jank/hydration complaint about an animated dashboard surface. Do NOT use for the WordPress theme's vanilla-CSS/Three.js motion (no React there — use css-cascade-discipline for keyframes and skyyrose-3d-web-os for scene motion), and do NOT use for non-animated component structure (frontend-patterns). |
| origin | ECC |
Motion System v4.2
Production-ready UI motion system for React / Next.js.
Focused on performance, accessibility, and usability — not decoration.
When to use
Observable events that trigger this skill:
- You are about to write or edit a
motion.* element, AnimatePresence, useScroll, useTransform, useAnimate, or layoutId inside frontend/.
- A dashboard surface under
frontend/app/admin/** animates and someone reports jank, a hydration warning, or an exit animation that never plays.
- A code review flags an animation as decorative, unstoppable, or missing a reduced-motion path.
- You are choosing an
AnimatePresence mode (the repo has 8 sites with no explicit mode — see Verification check 3).
Use this motion system when motion:
- Guides attention (e.g., onboarding, key actions)
- Communicates state (loading, success, error, transitions)
- Preserves spatial continuity (layout changes, navigation)
Appropriate Scenarios
- Interactive components (buttons, modals, menus)
- State transitions (loading → loaded, open → closed)
- Navigation and layout continuity (shared elements, crossfade)
Considerations
- Accessibility: Always support reduced motion
- Device adaptation: Adjust for low-end devices
- Performance trade-offs: Prefer responsiveness over visual smoothness
When NOT to use this skill
- The WordPress theme (
wordpress-theme/skyyrose-flagship/). There is no React there — motion is CSS keyframes plus vanilla Three.js. Use css-cascade-discipline for the keyframes and skyyrose-3d-web-os / threejs-animation for scene motion. Importing this skill's motion/react guidance into a PHP template produces code that cannot run.
- Non-animated component structure — composition, state, data fetching. That is
frontend-patterns.
- The motion is purely decorative, reduces clarity, or costs frame budget. Then the correct output of this skill is delete the animation, not tune it.
Inputs
Everything below must be true before you write a line of motion code. Absent input = stop, do not
substitute a default — a guessed package name produces imports that resolve to nothing.
| Required | How to confirm | If absent |
|---|
| The animation package actually installed | grep -E '"motion"|"framer-motion"' frontend/package.json | STOP. Neither present → the surface has no motion runtime; installing one is a dependency decision, not a styling decision. Ask before adding. |
frontend/node_modules present | ls -d frontend/node_modules | STOP. Run npm install (npm, never pnpm — ERR_INVALID_THIS on Node 22+). Type-check and tests below cannot run without it. |
| The file is a Client Component | first line is 'use client' | STOP. motion.* in a Server Component throws at build. Add the directive or move the animated subtree into its own client file. |
| A stated purpose for the motion | one sentence: what state does it communicate? | STOP. No answer = the motion is decorative. Delete it; see the Final Rule. |
Observed in this repo [repo]: frontend/package.json declares "framer-motion": "^12.38.0" and no motion
package. Therefore every import in frontend/ is from 'framer-motion' — confirmed at
frontend/app/admin/elite-studio/design/page.tsx:4. Writing from 'motion/react' here resolves to nothing.
Procedure
- Confirm the package.
grep -E '"motion"|"framer-motion"' frontend/package.json. Import from the one that exists. Never both — mixing breaks AnimatePresence context across the two schedulers.
- Confirm
'use client' at the top of the target file. Add it if the file is otherwise a Server Component and contains no server-only imports.
- State the purpose in one sentence. Attention / state / continuity. If none apply, stop and remove the animation instead of tuning it.
- Animate
transform and opacity only. If the design needs width/height/top/left, restructure (scale, translate, or a layout-parent transition) rather than animating a layout property.
- Pull durations and easings from
motionTokens, not from ad-hoc numbers, so timing stays consistent across the dashboard.
- Set
mode explicitly on every AnimatePresence. "wait" for modals/toasts/page transitions, "popLayout" for lists/tabs/dismissible cards, "sync" only when overlap is the intent.
- Add the reduced-motion path —
useReducedMotion() for the JS-driven values and the CSS media query for anything animated in a stylesheet. Reduced motion means reduced distance and duration, not a frozen UI.
- Give
AnimatePresence children a stable key. No key = no exit animation, silently.
- Match SSR and client initial state — always set
initial explicitly, never rely on an implicit origin, or hydration warns and the first frame flashes.
- Run the Verification checks below, all of them, and paste the output into the report.
How It Works
Core Principle
Motion must:
- Guide attention
- Communicate state
- Preserve spatial continuity
If it does none → remove it.
Installation
npm install motion
Version
motion/react - default for current Motion for React projects (package: motion)
framer-motion - legacy import path for projects that still depend on Framer Motion
Do not mix. Mixing causes conflicting internal schedulers and broken AnimatePresence contexts — components from one package will not coordinate exit animations with components from the other.
To check which version your project uses:
cat package.json | grep -E '"motion"|"framer-motion"'
Always import from one source consistently:
import { motion, AnimatePresence } from "motion/react"
import { motion, AnimatePresence } from "framer-motion"
Motion Tokens
export const motionTokens = {
duration: {
fast: 0.18,
normal: 0.35,
slow: 0.6
},
easing: {
smooth: [0.22, 1, 0.36, 1] as [number, number, number, number],
sharp: [0.4, 0, 0.2, 1] as [number, number, number, number]
},
distance: {
sm: 8,
md: 16,
lg: 24
}
}
Usage example:
import { motionTokens } from "@/lib/motionTokens"
<motion.div
initial={{ opacity: 0, y: motionTokens.distance.md }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: motionTokens.duration.normal,
ease: motionTokens.easing.smooth
}}
/>
Performance Rules
Safe
Avoid
- width / height
- top / left
Rule: responsiveness > smoothness
Device Adaptation
The heuristic combines CPU core count and available memory for a more reliable signal. deviceMemory is available on Chrome/Android; the fallback covers Safari and Firefox.
const isLowEnd =
typeof navigator !== "undefined" && (
(navigator.deviceMemory !== undefined && navigator.deviceMemory <= 2) ||
(navigator.deviceMemory === undefined && navigator.hardwareConcurrency <= 4)
)
const duration = isLowEnd ? 0.2 : 0.4
Accessibility
JS (useReducedMotion)
import { motion, useReducedMotion } from "motion/react"
export function FadeIn() {
const reduce = useReducedMotion()
return (
<motion.div
initial={{ opacity: 0, y: reduce ? 0 : 24 }}
animate={{ opacity: 1, y: 0 }}
/>
)
}
CSS
@media (prefers-reduced-motion: reduce) {
.motion-safe-transition {
transition: opacity 0.2s;
}
.motion-reduce-transform {
transform: none !important;
}
}
Tailwind
<div class="motion-safe:animate-fade motion-reduce:opacity-100"></div>
Architecture & Patterns
Core Patterns
| Scenario | Pattern |
|---|
| Hover feedback | whileHover |
| Tap / press feedback | whileTap |
| Reveal on scroll | whileInView |
| Scroll-linked value | useScroll + useTransform |
| Conditional mount/unmount | AnimatePresence |
| Small layout shifts (single element, < ~300px change) | layout prop |
| Large layout shifts or full-page reflows | Avoid layout; use CSS transitions or page-level routing instead |
| Complex, imperative sequences | useAnimate |
Why avoid layout on large containers? Framer's layout animation uses transform to reconcile positions, but on elements that span the full viewport or trigger deep reflow, the measurement cost causes visible jank and CLS. Prefer CSS Grid/Flexbox transitions or coordinate with layoutId on specific child elements only.
Layout & Transitions
- Shared element transitions →
layoutId (must be unique per mounted instance)
- Enter / exit transitions →
AnimatePresence (see mode guidance below)
AnimatePresence mode
Always specify mode explicitly — the default ("sync") runs enter and exit simultaneously, which causes visual overlap in most UI patterns.
mode | When to use |
|---|
"wait" | Exit completes before enter starts. Use for modals, toasts, page transitions. |
"sync" (default) | Enter and exit overlap. Use only when overlap is intentional (e.g., crossfade carousels). |
"popLayout" | Exiting element is popped out of flow immediately; remaining items animate to fill. Use for lists, tabs, dismissible cards. |
<AnimatePresence mode="wait">
{open && <Modal key="modal" />}
</AnimatePresence>
<AnimatePresence mode="popLayout">
{items.map(item => <Card key={item.id} />)}
</AnimatePresence>
Advanced Patterns (Concepts)
- Parallax (scroll-linked transforms)
- Scroll storytelling (sticky sections)
- 3D tilt (pointer-based transforms)
- Crossfade (shared
layoutId)
- Progressive reveal (clip-path)
- Skeleton loading (looped opacity)
- Micro-interactions (hover/tap feedback)
- Spring system (physics-based motion)
Modal Essentials
- Focus trap
- Escape close
- Scroll lock
- ARIA roles
- Use
AnimatePresence mode="wait" so exit animation completes before the next modal enters
Full Example
import React, { useEffect, useRef, useState } from "react"
import { motion, AnimatePresence } from "motion/react"
function useFocusTrap(ref: React.RefObject<HTMLDivElement | null>, active: boolean) {
useEffect(() => {
if (!active || !ref.current) return
const el = ref.current
const focusable = el.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const first = focusable[0]
const last = focusable[focusable.length - 1]
function handleKey(e: KeyboardEvent) {
if (e.key !== "Tab") return
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last?.focus()
} else (!e. && . === last) {
e.()
first?.()
}
}
el.(, handleKey)
first?.()
el.(, handleKey)
}, [active, ref])
}
() {
( {
(!active)
prev = ...
... =
{ ... = prev }
}, [active])
}
() {
ref = useRef<>()
(ref, open)
(open)
( {
() {
(e. === ) ()
}
(open) .(, onKey)
.(, onKey)
}, [open, closeModal])
(
)
}
() {
[open, setOpen] = ()
(
)
}
SSR Safety
- Match initial states between server and client renders
- Avoid implicit animation origins (always set
initial explicitly)
- Wrap motion components in
"use client" in Next.js App Router
Debugging
Check:
- Wrong import (mixing
motion/react and framer-motion)
- Missing
"use client" directive in Next.js App Router
- Missing
key prop on AnimatePresence children
- Hydration mismatch (initial state differs between SSR and client)
layout prop misuse on large containers causing reflow jank
- State-driven animation not triggering (check dependency arrays)
QA
- No CLS
- Keyboard works
- Focus trapped in modals
- ARIA roles correct (
role="dialog", aria-modal="true")
- Reduced motion respected (
useReducedMotion + CSS media query)
- No hydration warnings in Next.js
- Animations stop cleanly on unmount (no memory leaks)
AnimatePresence mode set explicitly on all usage sites
Anti-Patterns
- Animating layout properties (
width, height, top, left)
- Infinite animations without purpose (always ask: what state does this communicate?)
- Over-staggering lists (keep
staggerChildren ≤ 0.1s; beyond that it feels slow)
- Ignoring reduced motion preferences
- Using
layout on large or full-viewport containers
- Omitting
mode on AnimatePresence (default "sync" causes visual overlap)
- Using motion purely for decoration
Philosophy
Motion is interaction design.
Final Rule
If motion does not improve UX → remove it.
Verification
Run all four from /Users/theceo/DevSkyy/.claude/worktrees/glimmering-crafting-shannon. Every one can
return "no" — that is the point. Paste the real output; never report a check you did not run.
- No package mixing. The repo is
framer-motion-only; a stray motion/react import resolves to nothing at build time.
grep -rn "from ['\"]motion/react['\"]" frontend --include='*.tsx' --include='*.ts' \
--exclude-dir=node_modules | wc -l
PASS: prints 0. Any hit is a broken import — fix before proceeding. [repro] — observed 0 on 2026-07-28.
- Types still compile (catches wrong
ease tuple arity, bad variants shapes, mode typos):
cd frontend && npx tsc --noEmit
PASS: exits 0, no diagnostics. [test]
- Every
AnimatePresence sets mode explicitly. The default "sync" overlaps enter and exit; on a modal that reads as two dialogs on screen at once.
grep -rn "<AnimatePresence" frontend/app frontend/components --include='*.tsx' | grep -vc "mode="
PASS: prints 0.
Observed 2026-07-28: 8 — this check is RED in the current tree [repo]. 8 sites use bare
<AnimatePresence> against 4 with an explicit mode. That is a repo-state finding about committed
source; it is not a claim about rendered production behavior, which would need [live].
- Reduced motion is honoured wherever motion ships:
grep -rl "framer-motion" frontend/app frontend/components --include='*.tsx' | wc -l
grep -rl "useReducedMotion\|prefers-reduced-motion" frontend/app frontend/components frontend/lib | wc -l
PASS: the second count is > 0 and every animated route group is represented.
Observed 2026-07-28: 32 files import framer-motion, 0 reference reduced motion — RED [repo].
Treat this as the standing baseline: your change must not make it worse, and any file you touch
must gain the reduced-motion path.
Proving these checks can fail (rule 3). Check 1 was proven against a scratch tree on 2026-07-28, not
against the working copy: writing a single file containing import { motion } from 'motion/react'; under
a throwaway frontend/app/ made the grep print 1; deleting that file returned it to 0 [repro].
Checks 3 and 4 are already returning red, which is itself proof they discriminate. A check never
observed failing is a guess with a citation.
A gate that dies is not a gate that passed (rule 1). If npx tsc --noEmit is killed, times out, or
aborts on a resolver error, its silence is an artifact — re-run it, do not record it as clean.
A SKIP is not a PASS (rule 2). There is no eslint-plugin-jsx-a11y and no axe-core in
frontend/package.json [repo], so automated a11y assertion of an animated component is not
available here. Say so explicitly and name the closer (a human running the keyboard + screen-reader
pass, or a maintainer installing @axe-core/playwright). Silent omission reads as success.
Attribution before you claim a finding (rule 4). Checks 3 and 4 have a nonzero baseline, so a red
result after your edit does not mean you caused it. Extract the pristine tree and diff the lines, not
the counts:
mkdir -p /tmp/attr && git archive HEAD frontend/app frontend/components | tar -x -C /tmp/attr
grep -rn "<AnimatePresence" /tmp/attr/frontend/app /tmp/attr/frontend/components | grep -v "mode=" > /tmp/attr/before.txt
grep -rn "<AnimatePresence" frontend/app frontend/components | grep -v "mode=" > /tmp/attr/after.txt
diff /tmp/attr/before.txt /tmp/attr/after.txt
PASS: diff prints nothing, or only lines you intentionally added. [test]
Never git stash for this — the stash stack is shared across worktrees and you can pop another
session's work.
Worked example
Task (2026-07-28): confirm the dashboard's motion imports are consistent before adding an
AnimatePresence to an admin page.
Read the real import site:
$ sed -n '1,5p' frontend/app/admin/elite-studio/design/page.tsx
'use client';
import { useState, useRef, useCallback, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
Package check, exactly as the Inputs table requires:
$ grep -E '"motion"|"framer-motion"' frontend/package.json
"framer-motion": "^12.38.0",
framer-motion is the installed runtime and there is no motion package — so the correct import in this
repo is from 'framer-motion', and the motion/react snippets elsewhere in this document are the
legacy-path variant, not what you type here. [repo]
Verification check 1 then confirms nothing has drifted:
$ grep -rn "from ['\"]motion/react['\"]" frontend --include='*.tsx' --exclude-dir=node_modules | wc -l
0
Check 3, run in the same session, came back red:
$ grep -rn "<AnimatePresence" frontend/app frontend/components --include='*.tsx' | grep -vc "mode="
8
Correct report of that result: "8 AnimatePresence sites in the committed tree carry no explicit
mode [repo]. Whether any of them visibly overlaps in production is unverified — that needs a browser
against the deployed dashboard [live]." Reporting it as "8 broken modals in production" would be the
scope jump the evidence rules ban (bug-287).
Failure modes
| Symptom | Root cause | Fix |
|---|
| Exit animation never plays; element vanishes instantly | AnimatePresence child has no stable key, or the parent unmounts with it | Give the child a key; keep AnimatePresence mounted above the conditional |
| Two dialogs visible at once during a swap | mode omitted → defaults to "sync" | mode="wait" (Verification check 3 catches this repo-wide) |
Error: ... only works in Client Components at build | motion.* used in a Server Component | Add 'use client', or split the animated subtree into a client file |
| Hydration mismatch warning + first-frame flash | initial omitted, so SSR and client disagree on frame 0 | Always set initial explicitly |
Import resolves to nothing / AnimatePresence context missing | Mixed motion/react and framer-motion | One package only — Verification check 1 |
| Visible jank on a full-width container | layout prop on a viewport-spanning element; reconciliation measurement cost | Drop layout; use CSS grid/flex transitions, or layoutId on specific children |
| Animation ignores OS reduced-motion setting | No useReducedMotion() and no CSS media query | Verification check 4 — currently red repo-wide [repo] |
| List reveal feels sluggish | staggerChildren > 0.1s | Keep ≤ 0.1s |
| "Zero problems" from a check that was actually killed | Gate died mid-run; output is an artifact, not a result | Re-run by hand — bug-230 (×6), the fail-open pattern |
| A red gate blamed on your change | Pre-existing baseline (8 bare AnimatePresence, 0 reduced-motion refs) | Attribute against a pristine tree via git archive, diff contents not counts |
Examples
Button Interaction
import { motion } from "motion/react"
export function Button() {
return (
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
transition={{ duration: 0.15, ease: [0.4, 0, 0.2, 1] }}
>
Click me
</motion.button>
)
}
Reduced Motion Example
import { motion, useReducedMotion } from "motion/react"
export function FadeIn() {
const reduce = useReducedMotion()
return (
<motion.div
initial={{ opacity: 0, y: reduce ? 0 : 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.1 : 0.35, ease: [0.22, 1, 0.36, 1] }}
/>
)
}
Stagger List
import { motion } from "motion/react"
const container = {
hidden: {},
visible: {
transition: { staggerChildren: 0.08 }
}
}
const item = {
hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.22, 1, 0.36, 1] } }
}
export function List() {
return (
<motion.ul variants={container} initial="hidden" animate="visible">
{[1, 2, 3].map(i => (
<motion.li key={i} variants={item}>Item {i}</motion.li>
))}
</motion.ul>
)
}
Modal with AnimatePresence
import { motion, AnimatePresence } from "motion/react"
export function Modal({ open }: { open: boolean }) {
return (
<AnimatePresence mode="wait">
{open && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
/>
)}
</AnimatePresence>
)
}
Scroll Parallax
import { useScroll, useTransform, motion } from "motion/react"
export function Parallax() {
const { scrollYProgress } = useScroll()
const y = useTransform(scrollYProgress, [0, 1], [0, -80])
return <motion.div style={{ y }} />
}
Skeleton Loading
import { motion } from "motion/react"
export function Skeleton() {
return (
<motion.div
className="bg-gray-200 h-6 w-full rounded"
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{
duration: 1.5, // comfortable pulse — was missing, caused fast flash
repeat: Infinity,
ease: "easeInOut"
}}
/>
)
}
Shared Layout (Crossfade)
import { motion } from "motion/react"
export function Shared() {
return <motion.div layoutId="shared" />
}