Motion for React skill for Codex
Exit animations that actually play. Codex writes React animation with Motion the way it is meant to be used: state-driven props, AnimatePresence set up so exits really run, layout animations that keep text from stretching, and a shared MotionConfig that respects reduced motion. It knows the motion/react import path, the react-client variant for server components, and when LazyMotion saves real bytes.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- motion-animations Installs to ~/.agents/skills/motion-animations/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install motion-animations The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project motion-animations Installs to .agents/skills/motion-animations/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/motion-animations.zipunzip -o motion-animations.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) motion-animations PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $motion-animations, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Implements React animation with Motion (the library formerly called Framer Motion, imported from motion/react) - enter and exit with AnimatePresence, layout and shared-element transitions with layoutId, variants and stagger, gestures and drag, scroll-linked values, reduced motion and bundle trimming with LazyMotion. Use when a React or Next.js app needs exit animations, list reorder animations, a shared element morph, swipe-to-dismiss, scroll-linked effects, or when migrating imports from framer-motion.
Use it when
- A React component needs enter, exit or reorder animation
- You want a shared-element transition between a list item and a detail view
- Drag, swipe or scroll-linked effects are needed in React
- A codebase still imports from framer-motion
Use something else when
- Plain hover or open/close effects that CSS handles: use css-animations
- Timeline-heavy scroll storytelling with pinning: use gsap-animations
How to check the result
Toggle every conditional and confirm exits play, emulate prefers-reduced-motion to confirm transforms stop, and check the bundle analyzer for routes that should not include Motion.
The mistake it prevents
Wrapping AnimatePresence itself in the conditional, or using array indexes as keys, so the exiting element unmounts before its exit animation starts.
Done well means: components animate from state, not imperative timing hacks; exits actually play; layout animations do not distort text; reduced-motion users get a calm version; and the animation code does not ship to routes that never use it.
Before writing anything
- Read
AGENTS.mdandpackage.json. Note whether the project hasframer-motionormotion, the React version, and whether it uses the Next.js App Router (server components). - Grep for existing patterns:
rg "from \"(framer-motion|motion/react)\"" -nand any shared transition presets. Reuse them. - Installing or upgrading needs network:
npm install motion(or the project’s package manager). In a Codex sandbox without network, ask for approval first.
Migrating from framer-motion
The API is the same; the package and import path changed.
npm uninstall framer-motion && npm install motion
rg -l "from ['\"]framer-motion['\"]" src | xargs sed -i '' "s#from ['\"]framer-motion['\"]#from \"motion/react\"#"
(sed -i '' is BSD/macOS; drop the '' on GNU.) Run the type checker afterwards; a few
deprecated props from very old Framer Motion versions may surface.
Server components
motion.div uses hooks and must run on the client. In the Next.js App Router:
- Put animated UI in a file with
"use client", or - For a simple animated wrapper inside a server component, import
import * as motion from "motion/react-client"and use<motion.div>there.
Never add "use client" to a layout or page just to animate one child; push the boundary down.
Pattern table
| Goal | Tool |
|---|---|
| Fade/slide in on mount | initial + animate |
| Animate out before removal | AnimatePresence + exit |
| Swap one element for another (step wizard, tab panel) | AnimatePresence mode="wait" + changing key |
| Reorder lists, size changes | layout prop |
| Thumbnail grows into a modal | shared layoutId on both elements |
| Coordinated children | variants + delayChildren: stagger(...) |
| Hover / press feedback | whileHover, whileTap, whileFocus |
| Drag, swipe to dismiss | drag, dragConstraints, onDragEnd with velocity |
| Scroll-linked progress or parallax | useScroll + useTransform (+ useSpring) |
| Imperative sequence | useAnimate |
Enter and exit
"use client";
import { AnimatePresence, motion } from "motion/react";
export function Toasts({ items }: { items: Toast[] }) {
return (
<ul>
<AnimatePresence initial={false}>
{items.map((t) => (
<motion.li
key={t.id}
layout
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 40 }}
transition={{ type: "spring", stiffness: 500, damping: 40 }}
>
{t.message}
</motion.li>
))}
</AnimatePresence>
</ul>
);
}
Exit rules that cause most “exit does not play” bugs:
AnimatePresencemust stay mounted; the conditional goes inside it, not around it.- Each direct child needs a stable, unique
key(never the array index for reorderable lists). - The element with
exitmust be a direct child, or a custom component that renders a motion element and forwards its props. mode="wait"plays one exit before the next enter;mode="popLayout"removes the exiting element from layout flow immediately (pair it withlayouton siblings, and the child must forward a ref).
Layout animations
<motion.div layout transition={{ layout: { duration: 0.25 } }}>
<motion.h3 layout="position">{title}</motion.h3>
{expanded && <p>{body}</p>}
</motion.div>
- Layout animation uses transforms, so children can look stretched. Put
layouton children too, orlayout="position"on text so only position animates. borderRadiusandboxShadoware corrected automatically only when set viastyleor animated by Motion, not via a CSS class.- Components that re-layout together but are not siblings need
<LayoutGroup>. - Scrollable ancestors need
layoutScroll;position: fixedancestors needlayoutRoot.
Shared element:
{selected ? (
<motion.div layoutId={`card-${selected.id}`} className="modal">...</motion.div>
) : (
items.map((i) => <motion.div key={i.id} layoutId={`card-${i.id}`} onClick={() => select(i)} />)
)}
Wrap the swapping region in AnimatePresence when the old element should also fade.
Variants and stagger
import { motion, stagger } from "motion/react";
const list = { hidden: {}, show: { transition: { delayChildren: stagger(0.05) } } };
const item = { hidden: { opacity: 0, y: 8 }, show: { opacity: 1, y: 0 } };
<motion.ul variants={list} initial="hidden" animate="show">
{rows.map((r) => <motion.li key={r.id} variants={item}>{r.label}</motion.li>)}
</motion.ul>
Children inherit the variant label from the parent; do not set animate on them. Older code
uses staggerChildren: 0.05, which still works.
Gestures and drag
<motion.div
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.2}
onDragEnd={(_, info) => {
if (info.offset.x > 120 || info.velocity.x > 600) onDismiss();
}}
whileTap={{ scale: 0.98 }}
/>
- Drag must have a keyboard or button alternative (WCAG 2.5.7 Dragging Movements).
whileHoverdoes not fire on touch; never hide essential actions behind hover.
Scroll-linked values
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] });
const y = useTransform(scrollYProgress, [0, 1], [40, -40]);
const smooth = useSpring(scrollYProgress, { stiffness: 120, damping: 30 });
return <motion.section ref={ref} style={{ y }} />;
Motion values update outside React render; do not mirror them into useState on every frame.
Use useMotionValueEvent when something must react to a threshold.
Reduced motion and bundle size
import { LazyMotion, domAnimation, MotionConfig } from "motion/react";
import * as m from "motion/react-m";
export function Providers({ children }) {
return (
<MotionConfig reducedMotion="user">
<LazyMotion features={domAnimation} strict>{children}</LazyMotion>
</MotionConfig>
);
}
// then use <m.div> instead of <motion.div>
reducedMotion="user"disables transform and layout animations for users with the OS setting while keeping opacity and color. UseuseReducedMotion()for custom branches (e.g. skip parallax).LazyMotionwithmcomponents cuts the initial bundle; usedomMaxinstead ofdomAnimationonly when you need layout animations or drag.strictthrows if a fullmotioncomponent sneaks in.
Gotchas
- Animating
height: "auto"works, but combining it withlayouton the same element fights; pick one. initial={false}onAnimatePresenceskips enter animations on first paint, which avoids hydration flashes for server-rendered lists.- Changing
keyremounts the subtree and resets its state; use it deliberately. - A spring defined by
stiffness/dampingoverridesduration; to think in time, usevisualDuration+bounceinstead. Do not mix the two styles in one transition. - In tests (Jest/Vitest + jsdom) animations do not run; assert on the end state, or set
MotionGlobalConfig.skipAnimations = truein setup.
Verification
- Type check and build pass; no
framer-motionimports remain if migrating. - Every exit plays: toggle each conditional and watch in the browser at slow speed (DevTools Animations panel does not show JS animations; use the Performance panel).
- Emulate
prefers-reduced-motion: reduceand confirm transforms and layout motion stop. - Check bundle impact with the project’s analyzer; routes without motion do not import it.
- Drag and hover interactions have keyboard equivalents.
Output
Report: components changed, which pattern each uses (presence, layout, layoutId, variants,
gesture, scroll), reduced-motion behavior, and bundle effect if LazyMotion was introduced.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Motion for React"
short_description: "Exit animations that actually play"
default_prompt: "$motion-animations My AnimatePresence exit animation never runs, fix it" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.