
Nextjs Framer Motion Animations
- 133 installs
- 3 repo stars
- Updated June 29, 2026
- tristanmanchester/agent-skills
Use nextjs-framer-motion-animations for development tasks
About
nextjs-framer-motion-animations: A skill for development. This provides functionality for development workflows.
- nextjs-framer-motion-animations
Nextjs Framer Motion Animations by the numbers
- 133 all-time installs (skills.sh)
- +2 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #2,706 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tristanmanchester/agent-skills --skill nextjs-framer-motion-animationsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 133 |
|---|---|
| repo stars | ★ 3 |
| Last updated | June 29, 2026 |
| Repository | tristanmanchester/agent-skills ↗ |
What it does
Use nextjs-framer-motion-animations for development tasks
Files
Next.js + Motion/Framer Motion
Mission
Build small, purposeful, accessible animations in Next.js using Motion for React (the current package) or legacy framer-motion, without breaking server/client boundaries, performance, or usability.
Use this skill for
- First-render reveals and section entrances
- Hover, tap, and focus feedback on buttons, links, cards, tabs, and navigation
- Scroll-triggered reveals and modest scroll-linked effects
- Modals, drawers, dropdowns, accordions, tabs, and other enter/exit UI
- Layout and shared-element transitions with
layoutandlayoutId - Reorderable lists and light route-content transitions
- Debugging Motion behaviour in Next.js
Do not use this skill for
- GSAP-style timelines or cinematic sequences
- Canvas, WebGL, Three.js, or Lottie-led animation systems
- Heavy parallax or scroll-jacking storytelling
- Large creative-direction rewrites
- Pure CSS effects that do not justify client JavaScript, unless Motion is explicitly requested
Non-negotiables
- Prefer the lightest Motion API that solves the task.
- Preserve repo consistency. Do not mix
motionandframer-motionimports in the same diff unless the task is an explicit migration. - Keep animated logic in the smallest possible Client Component boundary.
- Respect reduced motion globally with
MotionConfig reducedMotion="user"and locally withuseReducedMotion()when behaviour must change. - Prefer reusable primitives, variants, and motion tokens over repeated inline animation objects.
- Do not add a global provider, root-layout Client Component, or route-wide animation system unless the request genuinely needs it.
Default workflow
1) Audit the codebase first
Inspect:
- Router type:
app/,pages/, or both. - Current package:
motion,framer-motion, or neither. - Existing animation patterns and design-system components.
- Candidate transition boundaries:
app/layout.tsx,app/template.tsx,pages/_app.tsx, shared UI shells. - Whether the change is local animation, mount/unmount animation, layout animation, shared-element animation, reorder, or scroll-linked animation.
If shell access is available, run:
node scripts/audit-nextjs-motion.mjs --root /path/to/repo
node scripts/inspect-motion-target.mjs path/to/target-file.tsx --root /path/to/repo
node scripts/plan-motion-change.mjs --root /path/to/repo --target path/to/target-file.tsx --task "user request"For broad skill iteration or repo-health checks, also run:
node scripts/check-motion-antipatterns.mjs --root /path/to/repo2) Choose a package strategy
Default rules:
- New work or modernised motion layer: prefer the current
motionpackage with imports frommotion/react. - Existing repo already on `framer-motion`: stay consistent unless the task explicitly includes migration.
- Passive App Router component with no hooks or client-only logic:
motion/react-clientcan be appropriate, but it is an exception, not the default. - Leaf animations with hooks, route state, presence, reorder, or interactivity: use a small Client Component boundary.
See references/MIGRATION.md and references/DECISION_TREE.md.
3) Choose the lightest correct API
Use this decision rule:
- Simple local animation:
motion.* - Bundle-sensitive shared shell:
m.*withLazyMotion - Repeated parent/child orchestration: variants plus
stagger - Mount/unmount or route exits:
AnimatePresence - Layout changes from React re-render:
layout - Shared-element transition:
layoutId - Sibling layout coordination or namespaced shared layout IDs:
LayoutGroup - Simple scroll reveal:
whileInView - Scroll-linked progress or parallax:
useScrollplus motion values - Imperative sequence or external trigger:
useAnimate - Design-system component wrapper:
motion.create()with ref forwarding
See references/EXPERT_PLAYBOOK.md and references/DECISION_TREE.md.
4) Wire it correctly for the router
App Router
- Passive, hook-free animation in a server-friendly file: consider
motion/react-client. - Interactive or hook-driven UI: create a small Client Component leaf and keep data fetching server-side.
- Client wrapper around server-rendered children: useful for modal shells, drawers, and local visibility wrappers.
- Route enter/exit choreography: mount a persistent Client shell from a layout so
AnimatePresencestays mounted. - Segment replay on navigation:
template.tsxis useful when you want remount semantics at a specific segment boundary.
See references/APP_ROUTER.md.
Pages Router
- Keep
AnimatePresencestable inpages/_app.tsxfor route transitions. - Key routed children by a stable value that changes when you actually want a transition. For dynamic routes,
router.asPathis usually safer thanrouter.route. - Do not rewrite
_app.tsxfor a one-off local animation.
See references/PAGES_ROUTER.md.
5) Apply the motion budget
Default ranges unless the user or design system says otherwise:
- Micro-interactions: 0.12s to 0.22s, scale no larger than 1.03, travel no more than 4px.
- Reveal / list entrance: 0.18s to 0.35s, travel 8px to 24px.
- Page / route transition: 0.22s to 0.45s, mostly opacity plus small Y translation.
- Layout animation: prefer Motion springs or
layout; do not fake these with large manual transforms.
See references/EXPERT_PLAYBOOK.md and references/PERFORMANCE.md.
6) Validate before finishing
Always check:
- No server/client boundary mistakes
- Reduced motion works
- Focus is preserved for interactive UI
- No unnecessary layout shift or stretched content
- No duplicate or conflicting route wrappers
- Project still builds
Run repo checks when available:
npm run lint
npm run buildFor repo audits or skill iteration, also run:
node scripts/check-motion-antipatterns.mjs --root /path/to/repoUse references/CHECKLIST.md before finalising. When improving the skill itself, use references/EVALUATION.md and node scripts/run-evaluation-pack.mjs.
Implementation rules
- Prefer animating transform and opacity. Avoid animating
top,left, large filters, and large shadows on big surfaces. - When possible, turn the existing root element into a Motion element instead of adding a new wrapper. Extra wrappers often break layout, refs, selectors, or spacing.
- If using
mplusLazyMotion, use: domAnimationfor standard animations, variants, exit, hover, tap, and focus.domMaxonly when you need layout animations or drag/pan.- Use
AnimatePresence initial={false}for app-level wrappers unless first-load animation is explicitly desired. - Use
AnimatePresence mode="wait"only when a single child should fully exit before the next enters. - Never key exit-sensitive children by array index.
- Start with
layoutbefore manual height choreography. If content stretches, addlayoutto the affected children or switch tolayout="position"for aspect-ratio changes. - If the scroll container is not the window, configure
viewport.rootor useuseInViewwith the correct root. - When animating
next/imageor image wrappers, preserve the layout box and animate transform or opacity rather than intrinsic size. - When wrapping design-system components, call
motion.create()outside render and make sure the wrapped component forwards its ref. - If the user did not explicitly ask for Motion and the effect is just a tiny hover or focus style on a static server-rendered element, CSS may be the cleaner answer.
Scripts
scripts/audit-nextjs-motion.mjs- inspects a repo, ranks likely target files, and emits JSON recommendations.scripts/inspect-motion-target.mjs- inspects one file and recommends boundary, import path, risks, and likely pattern fit.scripts/plan-motion-change.mjs- combines repo audit, target inspection, and task wording into a structured expert plan.scripts/check-motion-antipatterns.mjs- scans a repo for common Motion and Framer Motion anti-patterns and emits JSON findings.scripts/run-evaluation-pack.mjs- runs the bundled fixture-and-golden evaluation pack for skill iteration.scripts/scaffold-motion-primitives.mjs- copies template components fromassets/into a target directory, with optional import rewriting for legacyframer-motion.
Reference map
references/EXPERT_PLAYBOOK.md- API selection, heuristics, motion tokens, anti-patternsreferences/DECISION_TREE.md- fast pattern and boundary selectionreferences/APP_ROUTER.md- App Router boundaries, route shells,template.tsx, and server-friendly patternsreferences/PAGES_ROUTER.md-_app.tsx, keys, dynamic route nuancesreferences/RECIPES.md- copy/paste implementationsreferences/PERFORMANCE.md- bundle size, LazyMotion, layout and scroll performancereferences/ACCESSIBILITY.md- reduced motion, focus, modal guidancereferences/MIGRATION.md-framer-motiontomotionpackage strategyreferences/TROUBLESHOOTING.md- failure modes and fixesreferences/CHECKLIST.md- final review before finishingreferences/EVALUATION.md- trigger tests, scenario fixtures, anti-pattern scans, and golden-output review
Output expectations
When modifying a repo, finish with: 1. The files changed 2. The Motion API and pattern chosen 3. Boundary strategy and package migration decision, if any 4. Reduced-motion handling 5. Performance or bundle-size choices, if relevant 6. Manual validation notes or commands run 7. Any caveats the developer should know
Typical request mapping
- "Make this button feel better" -> micro-interaction recipe
- "Animate cards in on scroll" -> reveal recipe; variants if staggered
- "Add a smooth route transition in App Router" -> persistent layout-mounted shell or content wrapper, not a root rewrite by default
- "Animate this accordion or tab underline" ->
layout/layoutId/LayoutGroup - "Make this drag list reorder smoothly" ->
Reorder.Group/Reorder.Item - "This breaks in App Router" -> inspect boundary and import path before editing
- "Modernise our Framer Motion setup" -> audit first, then use
references/MIGRATION.md
import * as React from "react";
import { FadeInOnMount } from "@/components/motion/fade-in-on-mount";
export default function Template({
children,
}: {
children: React.ReactNode;
}) {
return <FadeInOnMount>{children}</FadeInOnMount>;
}
"use client"
import type { ReactNode } from "react"
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
import { motion } from "motion/react"
import { BadList } from "@/components/bad-list"
import { BadTabs } from "@/components/bad-tabs"
import { BadPassive } from "@/components/bad-passive"
import { BadFactory } from "@/components/bad-factory"
export default function Page() {
return (
<main>
<motion.div
initial={{ opacity: 0, top: 24, left: 12 }}
animate={{ opacity: 1, top: 0, left: 0 }}
>
Broken dashboard card
</motion.div>
<BadList />
<BadTabs />
<BadPassive />
<BadFactory />
</main>
)
}
"use client"
import * as React from "react"
import { motion } from "motion/react"
export function BadFactory() {
const MotionButton = motion.create("button")
return <MotionButton whileHover={{ scale: 1.05 }}>Broken factory</MotionButton>
}
"use client"
import { AnimatePresence, Reorder, motion } from "framer-motion"
const items = ["alpha", "beta", "gamma"]
export function BadList() {
return (
<AnimatePresence mode="popLayout">
{items.map((item, index) => (
<Reorder.Item key={index} value={item} as={motion.li}>
{item}
</Reorder.Item>
))}
</AnimatePresence>
)
}
"use client"
import * as motion from "motion/react-client"
import { useState } from "react"
export function BadPassive() {
const [open, setOpen] = useState(false)
return (
<motion.div animate={{ opacity: open ? 1 : 0.6 }}>
<button type="button" onClick={() => setOpen((value) => !value)}>
Toggle
</button>
</motion.div>
)
}
"use client"
import { motion } from "motion/react"
import { useState } from "react"
const items = ["Overview", "Billing", "Usage"]
export function BadTabs() {
const [active, setActive] = useState(items[0])
return (
<div className="flex gap-2">
{items.map((item) => {
const selected = item === active
return (
<button key={item} type="button" className="relative px-3 py-2" onClick={() => setActive(item)}>
<span>{item}</span>
{selected ? <motion.span layoutId="underline" className="absolute inset-x-0 -bottom-px h-0.5 bg-current" /> : null}
</button>
)
})}
</div>
)
}
{
"name": "anti-pattern-broken",
"private": true,
"dependencies": {
"next": "^15.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"motion": "^12.0.0",
"framer-motion": "^11.0.0"
}
}
import { SettingsDrawer } from "@/components/settings-drawer"
export default function SettingsPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-semibold">Settings</h1>
<SettingsDrawer initialOpen={false} />
</main>
)
}
"use client"
import { useState } from "react"
export function SettingsDrawer({ initialOpen = false }: { initialOpen?: boolean }) {
const [open, setOpen] = useState(initialOpen)
return (
<section className="mt-6">
<button
type="button"
className="rounded-md border px-4 py-2"
onClick={() => setOpen((value) => !value)}
>
{open ? "Close settings" : "Open settings"}
</button>
{open ? (
<aside className="mt-4 rounded-xl border bg-white p-6 shadow-xl">
<h2 className="text-lg font-semibold">Panel</h2>
<p className="mt-2 text-sm text-black/70">
Toggle preferences, notifications, and account details here.
</p>
</aside>
) : null}
</section>
)
}
{
"name": "app-router-client-modal",
"private": true,
"dependencies": {
"next": "^15.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"motion": "^12.0.0"
}
}
import { ProductCard } from "@/components/product-card"
const products = [
{ id: "lens", title: "Prime Lens", price: "€399", description: "Fast portrait lens." },
{ id: "tripod", title: "Travel Tripod", price: "€149", description: "Lightweight carbon legs." },
{ id: "bag", title: "Camera Bag", price: "€89", description: "Weatherproof everyday carry." }
]
export default function Page() {
return (
<main className="grid gap-4 p-8 md:grid-cols-3">
{products.map((product) => (
<ProductCard
key={product.id}
title={product.title}
price={product.price}
description={product.description}
/>
))}
</main>
)
}
type ProductCardProps = {
title: string
price: string
description: string
}
export function ProductCard({ title, price, description }: ProductCardProps) {
return (
<article className="rounded-2xl border border-black/10 bg-white p-6 shadow-sm">
<div className="mb-3 text-sm text-black/60">{price}</div>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 text-sm text-black/70">{description}</p>
</article>
)
}
{
"name": "app-router-passive-card",
"private": true,
"dependencies": {
"next": "^15.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"motion": "^12.0.0"
}
}
export default function TeamPage({ params }: { params: { team: string } }) {
return (
<section className="grid gap-4">
<h1 className="text-2xl font-semibold">Team {params.team}</h1>
<p className="text-sm text-white/70">Metrics and recent changes for this team.</p>
</section>
)
}
export default function DashboardPage() {
return (
<section className="grid gap-4">
<h1 className="text-2xl font-semibold">Overview</h1>
<p className="text-sm text-white/70">Account summary and activity feed.</p>
</section>
)
}
import type { ReactNode } from "react"
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-white/10 px-6 py-4">Dashboard</header>
<main className="px-6 py-8">{children}</main>
</div>
</body>
</html>
)
}
{
"name": "app-router-route-shell",
"private": true,
"dependencies": {
"next": "^15.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"motion": "^12.0.0"
}
}
import { Button } from "@/components/ui/button"
export default function Page() {
return (
<main className="p-8">
<Button type="button">Launch</Button>
</main>
)
}
"use client"
import * as React from "react"
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className = "", ...props },
ref
) {
return (
<button
{...props}
ref={ref}
className={[
"inline-flex items-center justify-center rounded-lg bg-black px-4 py-2 text-white",
className
].join(" ")}
/>
)
})
{
"name": "design-system-button",
"private": true,
"dependencies": {
"next": "^15.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"motion": "^12.0.0"
}
}
{
"name": "pages-router-legacy-route-transition",
"private": true,
"dependencies": {
"next": "^15.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"framer-motion": "^11.0.0"
}
}
import type { AppProps } from "next/app"
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
import { useRouter } from "next/router"
export default function BlogPostPage() {
const router = useRouter()
return (
<main className="p-8">
<h1 className="text-3xl font-semibold">{router.query.slug}</h1>
<p className="mt-3 text-sm text-black/70">
Blog content lives here and navigates between dynamic slugs.
</p>
</main>
)
}
import { SettingsTabs } from "@/components/settings-tabs"
export default function SettingsPage() {
const items = ["General", "Billing", "Security"]
return (
<main className="space-y-10 p-8">
<SettingsTabs id="primary" items={items} />
<SettingsTabs id="secondary" items={items} />
</main>
)
}
"use client"
import { useState } from "react"
export function SettingsTabs({ id, items }: { id: string; items: string[] }) {
const [active, setActive] = useState(items[0])
return (
<div className="flex gap-2 rounded-full border p-1">
{items.map((item) => {
const selected = item === active
return (
<button
key={item}
type="button"
className="relative rounded-full px-4 py-2 text-sm"
onClick={() => setActive(item)}
>
<span>{item}</span>
{selected ? <span className="absolute inset-x-2 -bottom-px h-0.5 bg-current" /> : null}
</button>
)
})}
</div>
)
}
{
"name": "shared-layout-tabs",
"private": true,
"dependencies": {
"next": "^15.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"motion": "^12.0.0"
}
}
{
"expectedRuleIds": [
"mixed-import-packages",
"root-layout-client",
"app-router-server-imports-client-motion",
"top-left-animation",
"animatepresence-index-key",
"layoutid-without-layoutgroup-id",
"motion-create-in-render",
"motion-react-client-with-hooks-or-client-directive",
"reorder-item-without-group"
],
"minimumErrorCount": 4
}
Golden summary
The audit should flag, at minimum:
- mixed
motionandframer-motionimports - a client root App Router layout
- server files importing client-only Motion paths
- index keys under
AnimatePresence - transform-avoidant
top/leftanimation - repeated
layoutIdusage withoutLayoutGroup id motion.create()inside rendermotion/react-clientused in a client-driven fileReorder.ItemwithoutReorder.Group
{
"repoRouter": "app-router",
"packageStrategy": "use-motion",
"recommendedImportPath": "motion/react",
"recommendedBoundary": "already-client",
"recommendedPattern": "presence",
"likelyFilesToChange": [
"components/settings-drawer.tsx"
]
}
Golden summary
- Change
components/settings-drawer.tsx - Keep the work in the existing client component
- Use
AnimatePresencefrommotion/react - Animate overlay and panel separately
- Mention stable keys, exit handling, and reduced-motion fallback
{
"repoRouter": "app-router",
"packageStrategy": "use-motion",
"recommendedImportPath": "motion/react-client",
"recommendedBoundary": "server-friendly-motion-react-client-candidate",
"recommendedPattern": "mount-reveal",
"likelyFilesToChange": [
"components/product-card.tsx"
]
}
Golden summary
- Change
components/product-card.tsx - Keep the component server-friendly in App Router
- Prefer
motion/react-client - Use a small mount reveal on the existing root element
- Mention reduced motion and verify no unnecessary
use clientboundary was added
{
"repoRouter": "app-router",
"packageStrategy": "use-motion",
"recommendedImportPath": "motion/react",
"recommendedBoundary": "persistent-route-shell-under-layout",
"recommendedPattern": "route-transition-shell",
"likelyFilesToChange": [
"app/layout.tsx",
"components/motion/route-transition-shell.tsx"
]
}
Golden summary
- Do not mark
app/layout.tsxas a client component - Create a small client route-transition shell rendered from
app/layout.tsx - Use
AnimatePresence initial={false}keyed by pathname - Keep transitions lightweight and route-content scoped
{
"repoRouter": "app-router",
"packageStrategy": "use-motion",
"recommendedImportPath": "motion/react",
"recommendedBoundary": "already-client",
"recommendedPattern": "design-system-motion-create",
"likelyFilesToChange": [
"components/ui/button.tsx"
]
}
Golden summary
- Change
components/ui/button.tsx - Use
motion.create()outside render - Preserve ref forwarding
- Add restrained
whileHover,whileTap, andwhileFocusstates - Avoid wrapper divs
{
"repoRouter": "pages-router",
"packageStrategy": "keep-framer-motion",
"recommendedImportPath": "framer-motion",
"recommendedBoundary": "pages-app-presence-wrapper",
"recommendedPattern": "pages-router-route-transition",
"likelyFilesToChange": [
"pages/_app.tsx"
]
}
Golden summary
- Keep the repo on
framer-motion - Edit
pages/_app.tsx - Use
AnimatePresencearound the routed component - Key by
router.asPathwhen dynamic param changes should animate - Keep the transition gentle and avoid architecture churn
{
"repoRouter": "app-router",
"packageStrategy": "use-motion",
"recommendedImportPath": "motion/react",
"recommendedBoundary": "already-client",
"recommendedPattern": "shared-layout",
"likelyFilesToChange": [
"components/settings-tabs.tsx"
]
}
Golden summary
- Change
components/settings-tabs.tsx - Use
LayoutGrouppluslayoutId - Namespace repeated tab groups with
LayoutGroup id - Keep the change inside the existing client component
{
"version": 1,
"scenarios": [
{
"id": "app-router-passive-card",
"type": "scenario",
"fixtureRoot": "fixtures/app-router-passive-card",
"targetFile": "components/product-card.tsx",
"task": "Add a subtle fade and y reveal to this server-rendered product card in App Router without turning the page into a Client Component.",
"goldenJson": "goldens/app-router-passive-card.json",
"goldenMarkdown": "goldens/app-router-passive-card.md"
},
{
"id": "app-router-client-modal",
"type": "scenario",
"fixtureRoot": "fixtures/app-router-client-modal",
"targetFile": "components/settings-drawer.tsx",
"task": "Animate this settings drawer opening and closing with exit animations and reduced motion support.",
"goldenJson": "goldens/app-router-client-modal.json",
"goldenMarkdown": "goldens/app-router-client-modal.md"
},
{
"id": "app-router-route-shell",
"type": "scenario",
"fixtureRoot": "fixtures/app-router-route-shell",
"targetFile": "app/layout.tsx",
"task": "Add a lightweight route-content transition between dashboard pages in App Router.",
"goldenJson": "goldens/app-router-route-shell.json",
"goldenMarkdown": "goldens/app-router-route-shell.md"
},
{
"id": "pages-router-legacy-route-transition",
"type": "scenario",
"fixtureRoot": "fixtures/pages-router-legacy-route-transition",
"targetFile": "pages/_app.tsx",
"task": "Add gentle page transitions between blog pages, but keep the repo on its existing Framer Motion setup.",
"goldenJson": "goldens/pages-router-legacy-route-transition.json",
"goldenMarkdown": "goldens/pages-router-legacy-route-transition.md"
},
{
"id": "shared-layout-tabs",
"type": "scenario",
"fixtureRoot": "fixtures/shared-layout-tabs",
"targetFile": "components/settings-tabs.tsx",
"task": "Animate the active tab underline as a shared layout indicator, and make sure repeated tab groups do not collide.",
"goldenJson": "goldens/shared-layout-tabs.json",
"goldenMarkdown": "goldens/shared-layout-tabs.md"
},
{
"id": "design-system-button",
"type": "scenario",
"fixtureRoot": "fixtures/design-system-button",
"targetFile": "components/ui/button.tsx",
"task": "Make our design-system button support Motion hover, tap, and focus states without adding extra wrapper divs.",
"goldenJson": "goldens/design-system-button.json",
"goldenMarkdown": "goldens/design-system-button.md"
},
{
"id": "anti-pattern-broken",
"type": "anti-pattern",
"fixtureRoot": "fixtures/anti-pattern-broken",
"task": "Audit this repo and identify the Motion and Framer Motion anti-patterns that should be fixed first.",
"goldenJson": "goldens/anti-pattern-broken.json",
"goldenMarkdown": "goldens/anti-pattern-broken.md"
}
]
}
{
"requiredSemanticFields": [
"files changed or files likely to change",
"chosen Motion API or Framer Motion API",
"boundary strategy",
"package migration decision",
"reduced-motion handling",
"performance or bundle-size notes",
"validation or build notes",
"caveats or follow-up risks"
],
"reviewQuestions": [
"Did the answer preserve the repo's existing package strategy unless migration was explicitly requested?",
"Did it choose the smallest viable Client Component boundary?",
"Did it avoid turning App Router layouts into client components unnecessarily?",
"Did it mention reduced motion for non-trivial movement?",
"Did it use route-aware guidance for App Router or Pages Router when transitions were requested?",
"Did it avoid index keys with AnimatePresence and mention LayoutGroup id when layoutId is reused?"
]
}
{
"shouldTrigger": [
"Add a smooth Framer Motion page transition between routes in my Next.js app.",
"Can you animate these cards in on scroll with Motion in App Router?",
"Fix why AnimatePresence exit animations are not working in my Next.js modal.",
"Make this button feel better with whileHover and reduced motion.",
"Help me migrate this Next.js repo from framer-motion to the motion package.",
"Use Motion layoutId for an animated tab underline in my Next app.",
"Add drag-to-reorder to this list with Framer Motion.",
"I need a lightweight route-content transition in the App Router dashboard.",
"Can you debug hydration issues caused by Motion in this Next.js component?",
"Wrap our design-system link component with Motion without breaking refs."
],
"shouldNotTrigger": [
"Build a GSAP ScrollTrigger landing page animation.",
"Make this CSS hover effect snappier without adding JavaScript.",
"Animate this Three.js scene with camera movement.",
"Create a Lottie intro animation for the hero.",
"I need a plain Tailwind accordion, no Framer Motion.",
"Optimise this SQL query.",
"Write a Python script to resize images.",
"Create a Next.js project from scratch.",
"Make this SVG spin forever with CSS keyframes.",
"Help me choose between React Spring and GSAP."
]
}
"use client";
import * as React from "react";
import { motion } from "motion/react";
export function FadeInOnMount({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.div>
);
}
"use client";
import * as React from "react";
import { useAnimate, useInView, stagger } from "motion/react";
export function ImperativeListReveal({
children,
}: {
children: React.ReactNode;
}) {
const [scope, animate] = useAnimate();
const inView = useInView(scope, { once: true, amount: 0.2 });
React.useEffect(() => {
if (!inView) return;
void animate(
"li",
{ opacity: [0, 1], y: [12, 0] },
{
delay: stagger(0.06),
duration: 0.24,
ease: [0.22, 1, 0.36, 1],
},
);
}, [animate, inView]);
return <ul ref={scope}>{children}</ul>;
}
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
export function ModalShell({
open,
onClose,
children,
}: {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<AnimatePresence>
{open ? (
<>
<motion.div
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
className="fixed inset-x-0 top-20 mx-auto max-w-lg rounded-2xl bg-white p-6 shadow-xl"
initial={{ opacity: 0, y: 16, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.div>
</>
) : null}
</AnimatePresence>
);
}
"use client";
import * as React from "react";
import { motion } from "motion/react";
export function MotionButton(
props: React.ComponentPropsWithoutRef<"button">,
) {
return (
<motion.button
{...props}
whileHover={{ y: -1 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
/>
);
}
"use client";
import * as React from "react";
import Link, { LinkProps } from "next/link";
import { motion } from "motion/react";
const LinkButtonBase = React.forwardRef<
HTMLAnchorElement,
LinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
>(function LinkButtonBase(props, ref) {
const { href, ...rest } = props;
return <Link ref={ref} href={href} {...rest} />;
});
const MotionLinkButton = motion.create(LinkButtonBase);
export { MotionLinkButton };
"use client";
import * as React from "react";
import { MotionConfig } from "motion/react";
const defaultTransition = {
duration: 0.22,
ease: [0.22, 1, 0.36, 1] as const,
};
export function MotionProvider({
children,
}: {
children: React.ReactNode;
}) {
return (
<MotionConfig reducedMotion="user" transition={defaultTransition}>
{children}
</MotionConfig>
);
}
import type { AppProps } from "next/app";
import { AnimatePresence, MotionConfig } from "motion/react";
export default function App({ Component, pageProps, router }: AppProps) {
return (
<MotionConfig reducedMotion="user">
<AnimatePresence initial={false} mode="wait">
<Component {...pageProps} key={router.asPath} />
</AnimatePresence>
</MotionConfig>
);
}
"use client";
import * as React from "react";
import { Reorder } from "motion/react";
export function ReorderList({
initialItems,
}: {
initialItems: string[];
}) {
const [items, setItems] = React.useState(initialItems);
return (
<Reorder.Group axis="y" values={items} onReorder={setItems}>
{items.map((item) => (
<Reorder.Item key={item} value={item}>
{item}
</Reorder.Item>
))}
</Reorder.Group>
);
}
"use client";
import * as React from "react";
import { motion } from "motion/react";
export function Reveal({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.div>
);
}
"use client";
import * as React from "react";
import { AnimatePresence, LazyMotion, MotionConfig, domAnimation } from "motion/react";
import * as m from "motion/react-m";
import { usePathname } from "next/navigation";
const transition = {
duration: 0.28,
ease: [0.22, 1, 0.36, 1] as const,
};
export function RouteTransitionShell({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
return (
<LazyMotion features={domAnimation} strict>
<MotionConfig reducedMotion="user">
<AnimatePresence initial={false} mode="wait">
<m.main
key={pathname}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={transition}
>
{children}
</m.main>
</AnimatePresence>
</MotionConfig>
</LazyMotion>
);
}
"use client";
import { motion, useScroll, useSpring } from "motion/react";
export function ScrollProgress() {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 240,
damping: 30,
mass: 0.4,
});
return (
<motion.div
className="fixed inset-x-0 top-0 h-1 origin-left bg-current"
style={{ scaleX }}
/>
);
}
// Motion package only.
// Use this in App Router when a passive Motion component is enough and you do
// not need Motion hooks or other client-only logic.
import * as motion from "motion/react-client";
export function MotionCard({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<motion.article
className={className}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
>
{children}
</motion.article>
);
}
"use client";
import * as React from "react";
import { LayoutGroup, motion } from "motion/react";
export function SharedTabs({
id,
items,
value,
onChange,
}: {
id: string;
items: { value: string; label: string }[];
value: string;
onChange: (value: string) => void;
}) {
return (
<LayoutGroup id={id}>
<div className="flex gap-4">
{items.map((item) => {
const selected = item.value === value;
return (
<button
key={item.value}
className="relative pb-2"
onClick={() => onChange(item.value)}
>
{item.label}
{selected ? (
<motion.div
layoutId="underline"
className="absolute inset-x-0 -bottom-px h-0.5 rounded-full bg-current"
/>
) : null}
</button>
);
})}
</div>
</LayoutGroup>
);
}
"use client";
import * as React from "react";
import { motion, stagger } from "motion/react";
const container = {
hidden: {},
show: {
transition: {
delayChildren: stagger(0.06),
},
},
};
const item = {
hidden: { opacity: 0, y: 10 },
show: { opacity: 1, y: 0 },
};
export function StaggerList({
children,
}: {
children: React.ReactNode;
}) {
return (
<motion.ul variants={container} initial="hidden" animate="show">
{children}
</motion.ul>
);
}
export function StaggerItem({
children,
}: {
children: React.ReactNode;
}) {
return <motion.li variants={item}>{children}</motion.li>;
}
MIT License
Copyright (c) 2026 OpenAI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Accessibility guide
Reduced motion is mandatory
Default every animated subtree to:
<MotionConfig reducedMotion="user">
{children}
</MotionConfig>This automatically disables transform and layout animations while keeping non-spatial feedback like opacity and colour changes.
Custom reduced-motion behaviour
Use useReducedMotion() when the animation logic itself should change.
Typical adjustments:
- Replace
xandytransitions with opacity changes - Disable parallax and scroll-linked movement
- Disable autoplaying decorative video or background motion
- Shorten or remove sequential choreography
"use client";
import { motion, useReducedMotion } from "motion/react";
export function Drawer({ open }: { open: boolean }) {
const reduce = useReducedMotion();
return (
<motion.aside
animate={{
opacity: open ? 1 : 0,
x: reduce ? 0 : open ? 0 : "100%",
}}
/>
);
}Focus and keyboard use
Animation must not interfere with focus order or visible focus styles.
Rules:
- Keep CSS focus outlines or rings visible on animated buttons and links
- When content mounts or unmounts, do not lose the user's current focus target unexpectedly
- For dialogs, drawers, and popovers, let an accessibility-focused component library manage focus trapping and restoration
Motion should animate the surface, not own accessibility semantics.
Readability
Avoid:
- large parallax movement on text
- repeated fade-in or shimmer on copy the user is trying to read
- motion that hides where content moved to
Prefer:
- short structural transitions
- small positional cues
- opacity or colour changes instead of large spatial movement in reduced-motion mode
Accessibility review checklist
- Reduced motion enabled in OS: the UI still works and remains understandable
- Keyboard navigation still reaches every control
- Focus ring remains visible during hover, tap, and press states
- Modals and drawers restore focus on close
- Scroll-linked motion has a meaningful reduced-motion fallback
App Router guide
Mental model
In App Router, layouts and pages are Server Components by default. Treat Motion as a local concern unless the user clearly needs a broader transition system.
General rules:
- Keep data fetching and server-only logic in server components.
- Push Motion to the smallest leaf that actually needs it.
- Pass only serialisable props into client components.
- Avoid turning
app/layout.tsxinto a Client Component just to animate one hero, card, or modal.
Pattern 1: passive Motion component with motion/react-client
Use this only when all of the following are true:
- the repo uses the modern
motionpackage - the animation is passive and hook-free
- you do not need
AnimatePresence, route hooks, scroll state, or local interactivity
Example:
import * as motion from "motion/react-client"
export function MotionCard({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<motion.article
className={className}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
{children}
</motion.article>
)
}Pattern 2: server parent, animated client leaf
Use when the animated part needs hooks, local state, or other client-only behaviour.
Good fit:
useReducedMotionAnimatePresencewhileInViewwith custom logicusePathnameuseScroll- drag or reorder
- any component already using client hooks
Server component:
import { AnimatedHero } from "./animated-hero"
export default async function Page() {
const data = await getData()
return <AnimatedHero title={data.title} subtitle={data.subtitle} />
}Client leaf:
"use client"
import { motion, useReducedMotion } from "motion/react"
export function AnimatedHero({
title,
subtitle,
}: {
title: string
subtitle: string
}) {
const reduceMotion = useReducedMotion()
return (
<motion.section
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<h1>{title}</h1>
<p>{subtitle}</p>
</motion.section>
)
}Pattern 3: client wrapper around server-rendered children
Use when the wrapper needs Motion but the inner content can stay server-rendered.
Good fit:
- modal shell
- drawer shell
- animated panel open/close
- local route-content wrappers
- visibility wrappers around server-rendered content
"use client"
import { AnimatePresence, motion } from "motion/react"
export function AnimatedShell({
open,
children,
}: {
open: boolean
children: React.ReactNode
}) {
return (
<AnimatePresence>
{open ? (
<motion.div
key="shell"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
>
{children}
</motion.div>
) : null}
</AnimatePresence>
)
}Pattern 4: persistent route shell for enter and exit choreography
Use when the user genuinely wants route-content transitions and the presence boundary must survive route changes.
Recommended structure
- Keep
app/layout.tsxas the stable server layout. - Render a small Client Component shell inside it.
- Put
AnimatePresencein that shell and key the routed content inside it.
Example app/layout.tsx
import type { ReactNode } from "react"
import { RouteTransitionShell } from "@/components/motion/route-transition-shell"
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<RouteTransitionShell>{children}</RouteTransitionShell>
</body>
</html>
)
}Pattern 5: segment replay with template.tsx
Use template.tsx when you want remount semantics at a specific segment boundary. It is useful for replaying enter animations, but it is not a complete substitute for a persistent exit-aware shell.
Example app/template.tsx
import { FadeInOnMount } from "@/components/motion/fade-in-on-mount"
export default function Template({ children }: { children: React.ReactNode }) {
return <FadeInOnMount>{children}</FadeInOnMount>
}Choosing the right pattern
- Choose Pattern 1 for passive, hook-free motion in modern Motion codebases.
- Choose Pattern 2 when the animated part needs client hooks or local state.
- Choose Pattern 3 when a thin wrapper can stay client-side while the inner content remains server-rendered.
- Choose Pattern 4 when route enter and exit choreography actually matters.
- Choose Pattern 5 when remount-on-navigation semantics are enough.
Common mistakes
- Marking the root layout as client for a one-off animation
- Passing functions from a server component into a client animation leaf
- Importing
framer-motiondirectly into an App Router server file - Replacing a server-rendered subtree with a large client rewrite just to animate one element
- Adding
AnimatePresenceeverywhere “just in case”
Final review checklist
Run this before finishing.
Scope
- The diff solves the specific request
- No unnecessary global animation architecture was introduced
- The chosen pattern is the narrowest correct one
Package and imports
- Import path matches the repository choice
motionandframer-motionare not mixed casuallymotion/react-clientis only used inmotionpackage codebases- Any
LazyMotionsetup uses the correct feature pack
Next.js architecture
- App Router server components stay server-side where possible
- Any Motion hook usage lives in a Client Component
- Client boundaries are intentionally small
- Props passed into client components are serialisable
- Route-transition wrappers stay mounted if exit animations are required
Pattern correctness
- Reveal uses local
initialandanimate - Scroll reveal uses
whileInViewunless state is required - Stagger timing lives on the parent
- Exit animation uses
AnimatePresencewith direct-child stable keys - Layout animation uses
layoutbefore bespoke size choreography - Shared elements use
layoutId, and repeated widgets useLayoutGroup idif needed - Reorder uses
Reorder.GroupandReorder.Item
Accessibility
- Reduced motion has been handled thoughtfully
- Hover-only cues also have keyboard or focus parity where relevant
- Focus management and click targets still work
- Important information is not hidden behind slow animation
Performance
- The chosen boundary does not pull a large subtree into the client without reason
- The route is not paying for a global provider unnecessarily
- If a heavy subtree was introduced, bundle impact was considered
- Images still reserve layout space
- Observer or wrapper count has not grown without need
Verification
- Relevant lint, typecheck, test, or build checks ran if available
- No obvious hydration mismatch risk remains
- Visual behaviour was sanity-checked in the affected UI
- Final answer names changed files, pattern choice, import path, and tuning knobs
Skill iteration
- If you changed the skill itself, run
node scripts/run-evaluation-pack.mjs - Review trigger coverage in
assets/evaluation-pack/trigger-tests.json - Recheck anti-pattern guidance with
node scripts/check-motion-antipatterns.mjs --root /path/to/repo
Decision tree
Use this to choose the narrowest correct pattern before editing.
Boundary choice shortcut
Use motion/react-client when
- the repo uses the modern
motionpackage - the file can stay server-friendly
- the animation is passive and hook-free
- you do not need
AnimatePresence, route hooks,useScroll,useAnimate, or local interactive state
Use a small Client Component leaf when
- you need
useReducedMotion,useInView,useScroll,useAnimate, or route hooks - you need
AnimatePresence - the component is already interactive or stateful
- the animation is local to one widget and should not drag a larger subtree into the client
Use a client wrapper around server-rendered children when
- the wrapper controls visibility or transition
- the wrapped content can stay server-rendered
- the boundary is smaller than converting the whole parent to a Client Component
Use a layout-mounted shell or pages/_app.tsx wrapper when
- you genuinely need route-content enter and exit choreography
- the transition should survive route changes because the presence boundary stays mounted
Choosing between closely related APIs
whileInView vs useInView
- Start with
whileInViewfor straightforward reveal-on-scroll. - Use
useInViewwhen entering the viewport must drive React state, imperative code, or cross-element coordination. - If the scroll container is not the window, make sure the correct root is configured.
layout vs manual height animation
- Start with
layoutfor accordions, chips, tab underlines, and small reflows. - Reach for manual height or
useAnimateonly whenlayoutcannot model the transition cleanly. - If content stretches during layout animation, try
layout="position"or addlayoutto the affected children.
Variants vs useAnimate
- Use variants for ordinary parent-child orchestration, stagger, and reusable patterns.
- Use
useAnimatefor small imperative sequences or when declarative variants become awkward. - Do not escalate to a timeline mindset for simple UI state changes.
motion vs m plus LazyMotion
- Use plain
motionfor local edits and small numbers of animated elements. - Use
mplusLazyMotionwhen Motion becomes broad in scope or bundle size is a real concern. - Prefer
domAnimationunless you truly need layout or drag features that requiredomMax.
If the request says “make it smoother”
- Reduce travel distance before increasing duration.
- Prefer opacity plus a small
ychange over bigger movement. - Remove unnecessary bounce from content surfaces.
- Try
layoutbefore manual size choreography. - Avoid stacking reveal, scale, rotate, and parallax on the same region.
If the request says “this breaks in App Router”
Check, in order: 1. Is a server file importing motion/react or framer-motion directly? 2. Does the file actually need hooks or can it stay passive with motion/react-client? 3. Can the animation move into a smaller client leaf instead of making the parent or layout client-side? 4. Are only serialisable props being passed into the client component? 5. Is the presence boundary staying mounted if exit animations are expected?
If bundle size suddenly matters
- Prefer CSS for tiny hover or focus effects when Motion is not a requirement.
- Keep Client Component boundaries small.
- Consider
LazyMotionfor large motion-heavy subtrees. - Lazy-load animation-heavy islands such as rarely opened drawers or modals.
- Inspect actual import paths before assuming the problem is Motion itself.
Evaluation pack
Use this when you are improving the skill itself, reviewing a repo before broad edits, or checking whether your Motion guidance is still "expert" rather than merely plausible.
What the pack tests
The pack is split into three layers:
1. Trigger coverage
assets/evaluation-pack/trigger-tests.json- Positive prompts that should load the skill
- Negative prompts that should not
2. Scenario fixtures + golden decisions
assets/evaluation-pack/fixtures/assets/evaluation-pack/goldens/- Small repos that represent the most failure-prone decisions:
- passive App Router reveal
- client-side presence UI
- App Router route shell
- Pages Router legacy route transitions
- shared-layout tabs
- design-system wrapper work
3. Anti-pattern detection
scripts/check-motion-antipatterns.mjs- Static checks for common mistakes such as:
- mixed
motion/framer-motion - server files importing client-only Motion paths
- root layout widened to client without cause
- index keys under
AnimatePresence top/leftanimation instead of transforms- repeated
layoutIdwithoutLayoutGroup id motion.create()inside render
Scripts
Scenario-aware planner
Use this before non-trivial edits:
node scripts/plan-motion-change.mjs --root /path/to/repo --target path/to/file.tsx --task "Add a subtle route transition"It combines repo audit + target inspection + task wording, then returns JSON with:
- package strategy
- import-path choice
- boundary choice
- recommended pattern
- first steps
- reduced-motion plan
- performance plan
- validation checklist
Anti-pattern scan
node scripts/check-motion-antipatterns.mjs --root /path/to/repoReturns JSON with repo-level warnings and file-level issues.
Full evaluation pack
node scripts/run-evaluation-pack.mjs
node scripts/run-evaluation-pack.mjs --case app-router-route-shell
node scripts/run-evaluation-pack.mjs --listThe evaluator runs the planner or anti-pattern scanner against the bundled fixtures and compares the result to the corresponding golden JSON.
Expert bar
Treat the skill as "expert" only when all of these are true:
- It chooses the repo's existing package strategy unless migration is explicit.
- It keeps the Client Component boundary as small as possible.
- It distinguishes App Router route shells from
template.tsxreplay semantics. - It treats
AnimatePresence,layoutId,LayoutGroup id, andmotion.create()as correctness-sensitive APIs. - It accounts for reduced motion instead of treating it as optional polish.
- It explains validation, not just implementation.
Manual review prompts
Use these when reviewing agent output:
- Did the answer keep the change local?
- Did it pick the right import path for the repo?
- Did it choose the correct Next.js boundary?
- Did it mention reduced motion?
- Did it avoid route-transition overreach?
- Did it call out likely failure modes before they happen?
- Did it describe how to validate the change?
Output contract
See assets/evaluation-pack/output-contract.json.
The final answer should usually name:
- files changed or files likely to change
- chosen Motion API / pattern
- boundary strategy
- package decision
- reduced-motion handling
- performance considerations
- validation notes
- caveats
Expert playbook
Package strategy
Use one package strategy per diff:
- Current default:
motion - Legacy compatibility:
framer-motion - Do not mix both in the same patch unless the task is an explicit migration.
Use the current package when:
- Installing animation support from scratch
- Touching many imports anyway
- Building a new shared motion layer
Stay on legacy framer-motion when:
- The repo already uses it heavily
- The task is small and package churn would dominate the diff
- The user asked for the smallest possible change
API selection matrix
| Problem | Best first choice | Escalate to |
|---|---|---|
| Hover, tap, focus, simple entrance | motion.* | variants if repeated across children |
| Reusable coordinated list or card grid | variants plus stagger | useAnimate for imperative sequencing |
| Modal, drawer, toast, conditional panel | AnimatePresence | useAnimate plus usePresence for custom exits |
| Accordion, expanding card, layout reflow | layout | LayoutGroup if siblings affect each other |
| Shared underline, card-to-detail, selected pill | layoutId | LayoutGroup id when multiple instances exist |
| Reveal on scroll | whileInView | useInView plus useAnimate if custom timeline is needed |
| Scroll-linked progress or parallax | useScroll plus useTransform | useSpring to smooth linked values |
| Design-system button, link, card | motion.create() | explicit wrappers per component family |
Motion tokens
Start here before tuning.
Easing
Use one default easing curve across the feature:
const easeOut = [0.22, 1, 0.36, 1] as constMicro-interactions
const microTransition = { duration: 0.16, ease: easeOut }Reveal / entrance
const revealTransition = { duration: 0.28, ease: easeOut }Springs
Use springs when the UI is reacting to stateful layout changes rather than decorative fades.
const gentleSpring = { type: "spring", stiffness: 260, damping: 24, mass: 0.9 } as const
const snappySpring = { type: "spring", stiffness: 420, damping: 32, mass: 0.7 } as constGuideline:
- Use tween for simple opacity and transform reveals.
- Use spring for toggles, expanding panels, and layout-driven movement.
- Use
visualDurationonly when you need a spring to visually line up with time-based transitions.
Strong opinions
- Default to subtle motion. Most UIs improve with less movement, not more.
- Use one primary animation idea per surface. Do not layer hover scale, shadow bloom, blur, and parallax onto the same component unless the user explicitly wants it.
- Choose layout animation instead of manual height or position hacks when the movement is caused by a React re-render.
- Choose shared layout instead of manually syncing coordinates for tabs, pills, and selected cards.
- Choose imperative sequencing only when declarative props become harder to reason about.
Route transition rules
- If you need exit animations,
AnimatePresencemust stay mounted while the child changes. - In App Router, prefer a persistent client shell under a layout for exit choreography.
- Use
template.tsxwhen you want segment remount semantics and replayed enter animations, not as the only tool for all route exits. - In Pages Router, keep presence in
_app.tsx. - For dynamic routes in Pages Router,
router.routewill not change across param changes. Userouter.asPathwhen those changes should animate.
Layout animation rules
- If content looks stretched, add
layoutto the affected children too. - If an image or text block changes aspect ratio, prefer
layout="position"so only position animates. - If you reuse the same
layoutIdin multiple repeated groups, namespace them withLayoutGroup id. - When mixing exiting children and layout animation, be ready to wrap related elements in
LayoutGroup.
Scroll rules
- Reveal and scroll-linked animation are different tasks. Use
whileInViewfor reveal,useScrollfor scroll-linked values. - Avoid parallax on long text blocks.
- For progress bars, map
scrollYProgressthroughuseSpringso the bar feels smooth rather than noisy.
Accessibility rules
- Every animated subtree should either inherit
MotionConfig reducedMotion="user"or explicitly honouruseReducedMotion(). - Reduced motion does not mean zero feedback. Prefer opacity or colour changes over large transforms.
- For modals and drawers, let a dialog library own focus management. Motion should own visuals.
Anti-patterns
- Mixing
motionandframer-motionimports - Mounting and unmounting
AnimatePresenceitself - Keying exit-sensitive nodes by array index
- Using
topandleftwhenxandywould do - Animating huge shadows or blurs on large surfaces
- Calling
motion.create()inside render - Reusing global
layoutIdvalues across multiple independent tab sets - Treating
template.tsxas if it remounts on search-param-only changes
Migration guide
Current package naming
Motion for React is now published as the motion package.
Preferred install:
npm install motionPreferred imports:
import { motion } from "motion/react"Legacy framer-motion
Many repos still use framer-motion. That is fine if the task is small and you want the minimum diff.
Stay vs migrate
Stay on legacy framer-motion when:
- The repo already uses it
- The change touches only a few files
- The user wants a minimal patch
Migrate to motion when:
- You are installing Motion from scratch
- You are already touching many imports
- You want the current package naming and docs alignment
- You are building a fresh motion layer or shared primitives library
Minimal migration steps
npm uninstall framer-motion
npm install motionThen swap imports:
// before
import { motion, AnimatePresence } from "framer-motion"
// after
import { motion, AnimatePresence } from "motion/react"Important migration rules
- Migrate in one focused diff if possible.
- Do not leave a codebase half on
framer-motionand half onmotion. - If the codebase uses
mplusLazyMotion, the modern imports are: import * as m from "motion/react-m"import { LazyMotion, domAnimation, domMax } from "motion/react"
App Router note
Motion docs also provide:
import * as motion from "motion/react-client"Use that only when you explicitly need Motion components from a Server Component context. Most animation logic should still live in Client Components.
Older APIs you may see
If you encounter older shared-layout code:
- Replace
AnimateSharedLayoutpatterns withlayoutId - Use
LayoutGroupwhen multiple components affect each other's layout or when repeated shared-layout groups need namespacing
Suggested migration sequence
1. Run the audit script 2. Decide whether this task is a small local patch or an explicit migration 3. If migrating, update package and imports first 4. Run the build before changing animation behaviour 5. Then refactor patterns, wrappers, and recipes
Pages Router guide
Core pattern
In Pages Router, the most reliable route-transition setup is a stable AnimatePresence in pages/_app.tsx and an animated page wrapper inside each page or shared layout.
_app.tsx wiring
Keep the presence wrapper mounted at the app root.
import type { AppProps } from "next/app";
import { AnimatePresence, MotionConfig } from "motion/react";
export default function App({ Component, pageProps, router }: AppProps) {
return (
<MotionConfig reducedMotion="user">
<AnimatePresence initial={false} mode="wait">
<Component {...pageProps} key={router.asPath} />
</AnimatePresence>
</MotionConfig>
);
}See assets/pages-app.tsx.
Which key to use
Choose the key based on what should count as a new page:
router.asPath- Best when dynamic param changes should animate
- Includes query string and hash, so query-only changes can also retrigger transitions
router.route- Stable route pattern like
/posts/[slug] - Good when you only want transitions between different route templates
- Not enough when
/posts/aand/posts/bshould animate differently
Rule of thumb:
- Start with
router.asPath - If query-string transitions become noisy, switch to a path-only key derived from
router.asPath.split("?")[0]
Page wrapper
A page still needs its own animated surface.
import { motion } from "motion/react";
export default function Page() {
return (
<motion.main
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
>
...
</motion.main>
);
}Navigation notes
Prefer Next.js <Link> for navigation. Use useRouter() only when you need programmatic navigation from a Client Component.
Common mistakes
- Using
router.routeand expecting dynamic-param transitions to fire - Mounting
AnimatePresenceinside each page instead of_app.tsx - Forgetting an
exitstate on the animated page wrapper - Letting two competing wrappers animate the same page surface
Performance guide
Keep Client Components small
Push Motion to the smallest leaf that actually needs it. A local reveal should not turn an entire page, layout, or data-fetching branch into client code.
Use motion/react-client for passive App Router cases
If the repo already uses the modern motion package and the animation is passive and hook-free, motion/react-client can keep the file server-friendly.
Do not use it for:
AnimatePresence- route hooks
useScrolluseAnimate- local interactive state
Plain motion vs m plus LazyMotion
- Use plain
motionfor local edits and small scopes. - Use
mplusLazyMotionwhen Motion becomes broad in scope or bundle size is a real concern.
Feature packs
domAnimationfor standard animations, hover, tap, focus, variants, and exit.domMaxonly when you need layout or drag features that require the fuller bundle.
Use MotionConfig for subtree defaults
If multiple components in the same subtree share reduced-motion handling or default transitions, use MotionConfig locally instead of repeating config everywhere.
Lazy-load heavy animation islands
Rarely opened drawers, modals, or dashboards with lots of motion can often be split or loaded lazily. Prefer local dynamic boundaries over global motion infrastructure.
Prefer transform and opacity
These are usually the safest properties to animate. Avoid animating top, left, large filters, or expensive paint-heavy effects on large surfaces.
Layout animation details
- Start with
layoutbefore manual height animation. - If only position should animate, try
layout="position". - If layout animation causes distortion, give affected children their own
layouthandling.
Scroll performance
- Prefer
whileInViewfor ordinary reveal-on-scroll. - Reserve
useScrollfor effects that genuinely need continuous scroll linkage. - Keep observer and wrapper count under control; one well-placed wrapper often beats many tiny ones.
Protect image layout
When animating next/image or its wrapper:
- preserve the layout box
- keep reserved space intact
- animate transform or opacity rather than intrinsic size
Inspect before optimising blindly
Do not assume Motion is the only source of bundle growth or jank. Check:
- actual import paths
- whether the client boundary widened
- whether a new global provider was added
- whether the route now mounts more animated DOM than before
When not to animate
Skip or reduce Motion when:
- the effect can be handled cleanly in CSS and Motion was not required
- the route is highly performance-sensitive and the effect is purely decorative
- reduced-motion users would receive little value from the movement
Recipes
Examples below use the current motion package. If the repo stays on legacy framer-motion, swap imports consistently.
1) Subtle button micro-interaction
"use client";
import * as React from "react";
import { motion } from "motion/react";
export function MotionButton(
props: React.ComponentPropsWithoutRef<"button">,
) {
return (
<motion.button
{...props}
whileHover={{ y: -1 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
/>
);
}Use for:
- CTA buttons
- icon buttons
- menu triggers
Avoid:
- large scale jumps
- removing visible focus styles
2) Reveal on scroll
"use client";
import * as React from "react";
import { motion } from "motion/react";
export function Reveal({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.div>
);
}Use whileInView for simple reveal. Switch to useInView plus useAnimate only when the timeline is custom.
3) Staggered list
"use client";
import * as React from "react";
import { motion, stagger } from "motion/react";
const container = {
hidden: {},
show: {
transition: {
delayChildren: stagger(0.06),
},
},
};
const item = {
hidden: { opacity: 0, y: 10 },
show: { opacity: 1, y: 0 },
};
export function StaggerList({
children,
}: {
children: React.ReactNode;
}) {
return (
<motion.ul variants={container} initial="hidden" animate="show">
{children}
</motion.ul>
);
}
export function StaggerItem({
children,
}: {
children: React.ReactNode;
}) {
return <motion.li variants={item}>{children}</motion.li>;
}Use when many siblings share the same entrance idea.
4) Modal or drawer shell
Pair Motion with a dialog library for semantics and focus management.
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
export function ModalShell({
open,
onClose,
children,
}: {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<AnimatePresence>
{open ? (
<>
<motion.div
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
className="fixed inset-x-0 top-20 mx-auto max-w-lg rounded-2xl bg-white p-6 shadow-xl"
initial={{ opacity: 0, y: 16, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.div>
</>
) : null}
</AnimatePresence>
);
}5) Shared underline for tabs
"use client";
import * as React from "react";
import { LayoutGroup, motion } from "motion/react";
export function Tabs({
id,
items,
value,
onChange,
}: {
id: string;
items: { value: string; label: string }[];
value: string;
onChange: (value: string) => void;
}) {
return (
<LayoutGroup id={id}>
<div className="flex gap-4">
{items.map((item) => {
const selected = item.value === value;
return (
<button
key={item.value}
className="relative pb-2"
onClick={() => onChange(item.value)}
>
{item.label}
{selected ? (
<motion.div
layoutId="underline"
className="absolute inset-x-0 -bottom-px h-0.5 rounded-full bg-current"
/>
) : null}
</button>
);
})}
</div>
</LayoutGroup>
);
}Use LayoutGroup id when multiple tab rows may exist on the same page, because layoutId is global.
6) Accordion layout animation
"use client";
import * as React from "react";
import { LayoutGroup, motion } from "motion/react";
export function AccordionItem({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
const [open, setOpen] = React.useState(false);
return (
<motion.section layout className="rounded-xl border">
<button
className="w-full p-4 text-left"
onClick={() => setOpen((value) => !value)}
>
{title}
</button>
{open ? (
<motion.div
layout
className="px-4 pb-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{children}
</motion.div>
) : null}
</motion.section>
);
}
export function Accordion({
children,
}: {
children: React.ReactNode;
}) {
return <LayoutGroup>{children}</LayoutGroup>;
}7) App Router route shell
Use this when the task explicitly calls for route enter and exit choreography.
"use client";
import * as React from "react";
import { AnimatePresence } from "motion/react";
import { motion } from "motion/react";
import { usePathname } from "next/navigation";
export function RouteTransitionShell({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
return (
<AnimatePresence initial={false} mode="wait">
<motion.main
key={pathname}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.main>
</AnimatePresence>
);
}Mount this shell from a layout so AnimatePresence stays mounted.
8) Pages Router _app.tsx
import type { AppProps } from "next/app";
import { AnimatePresence, MotionConfig } from "motion/react";
export default function App({ Component, pageProps, router }: AppProps) {
return (
<MotionConfig reducedMotion="user">
<AnimatePresence initial={false} mode="wait">
<Component {...pageProps} key={router.asPath} />
</AnimatePresence>
</MotionConfig>
);
}9) Scroll progress bar
"use client";
import { motion, useScroll, useSpring } from "motion/react";
export function ScrollProgress() {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 240,
damping: 30,
mass: 0.4,
});
return (
<motion.div
className="fixed inset-x-0 top-0 h-1 origin-left bg-current"
style={{ scaleX }}
/>
);
}10) Imperative reveal with useAnimate
Use this when the sequence depends on an effect, intersection state, or custom timing that is awkward with props alone.
"use client";
import * as React from "react";
import { useAnimate, useInView, stagger } from "motion/react";
export function ImperativeListReveal({
children,
}: {
children: React.ReactNode;
}) {
const [scope, animate] = useAnimate();
const inView = useInView(scope, { once: true, amount: 0.2 });
React.useEffect(() => {
if (!inView) return;
void animate(
"li",
{ opacity: [0, 1], y: [12, 0] },
{ delay: stagger(0.06), duration: 0.24, ease: [0.22, 1, 0.36, 1] },
);
}, [animate, inView]);
return (
<ul ref={scope}>
{children}
</ul>
);
}11) Passive App Router card with motion/react-client
import * as motion from "motion/react-client"
export function MotionCard({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<motion.article
className={className}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
>
{children}
</motion.article>
)
}Use this only when you do not need hooks, AnimatePresence, or other client-only logic.
12) Reorder list
"use client"
import { Reorder } from "motion/react"
import { useState } from "react"
export function ReorderList({ initialItems }: { initialItems: string[] }) {
const [items, setItems] = useState(initialItems)
return (
<Reorder.Group axis="y" values={items} onReorder={setItems}>
{items.map((item) => (
<Reorder.Item key={item} value={item}>
{item}
</Reorder.Item>
))}
</Reorder.Group>
)
}Troubleshooting
Nothing animates
- Check that the component actually renders a Motion element such as
motion.div. - Confirm the repo import path is consistent with the installed package.
- Make sure the triggering prop or key is changing when you expect an animation.
- In App Router, verify that client-only Motion imports are not sitting in a server file.
App Router says a component needs use client
You are probably importing motion/react or framer-motion into a server file.
Fixes:
- Move the animated piece into a small Client Component leaf.
- Or, for passive hook-free Motion in a modern Motion codebase, use
motion/react-client. - Do not make
app/layout.tsxclient-side unless the request truly needs a global client boundary.
Exit animations do not fire
- The exiting element must be a direct child of
AnimatePresence. - The child must have a stable
key. - The presence boundary must stay mounted long enough for the exit to happen.
- In App Router, template remounts alone will not give you a persistent exit-aware shell.
Enter animations run when they should not
- Use
initial={false}when the first paint should match the final state. - Check whether the component is remounting because of a changing
key. - Verify that
template.tsxremount semantics are actually wanted.
Dynamic Pages Router routes do not transition
- In
_app.tsx, key byrouter.asPathwhen dynamic param changes matter. - Do not key by a value that stays constant across the transitions you care about.
Layout animation is not happening or looks stretched
- Start with
layoutbefore manual size choreography. - If only position should animate, try
layout="position". - If children distort, add
layoutto affected descendants as well. - Confirm the layout is actually changing between renders.
Shared-element underline animates in the wrong group
layoutIdis global within the current layout tree.- In repeated widgets, wrap each instance in
LayoutGroup id. - Ensure only one active shared element with a given
layoutIdexists per group.
mode="popLayout" looks broken
- The parent needs a non-static position.
- Check for clipping or stacking-context side effects.
- Use it only when you truly need the exiting item popped out of layout.
Scroll reveal fires against the wrong container
- If the scroll container is not the window, configure
viewport.root. - If you need more control, switch to
useInViewwith the correct root. - Avoid stacking multiple observers when one container-level approach would do.
Motion feels too heavy or distracting
- Reduce distance before reducing duration.
- Prefer opacity plus a small
ychange. - Remove bounce from content surfaces unless the design system calls for it.
- Re-check whether Motion is even necessary for the requested effect.
Hydration mismatch or flashing
- Keep the server-rendered structure stable.
- Avoid using route hooks or browser-only APIs in server files.
- Use
initial={false}when first paint should not animate in. - Be careful when wrappers change DOM structure around server-rendered content.
Reduced motion is not handled
- Add
MotionConfig reducedMotion="user"at an appropriate subtree boundary. - Use
useReducedMotion()when behaviour, not just timing, should change. - Replace large movement with opacity-only changes when reasonable.
Styling or refs break after wrapping with Motion
- Prefer converting the existing root element to a Motion element rather than adding an extra wrapper.
- If you must wrap a design-system component, use
motion.create()outside render. - Make sure the wrapped component forwards its ref.
LazyMotion complains or bundle size jumped
domAnimationis the default feature pack.domMaxis only for layout, drag, or features that truly need it.- Keep
LazyMotionlocal to the subtree that benefits from it. - Check whether a simple local
motionimport would actually be cheaper operationally for the task.
CSP blocks Motion styles
- Some Motion features rely on runtime style injection.
- Check the project CSP and nonce setup before blaming the component code.
- If CSP is strict, prefer patterns that fit the project’s existing allowances or adjust CSP deliberately.
Template-based transition did not retrigger
template.tsxremounts on segment changes, not every possible state change.- Search-param-only changes may not behave the way you expect.
- If you need a guaranteed content transition on pathname change, use a client wrapper keyed by pathname.
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const HELP = `Usage: node scripts/audit-nextjs-motion.mjs [--root PATH] [--limit N]
Inspect a Next.js repo for Motion / Framer Motion work. Prints JSON to stdout.
Options:
--root PATH Repository root to inspect. Defaults to current working directory.
--limit N Maximum number of candidate files to return. Defaults to 16.
--help Show this help text.
Examples:
node scripts/audit-nextjs-motion.mjs --root ../my-app
node scripts/audit-nextjs-motion.mjs --root /workspace/repo --limit 24
`;
const IGNORE_DIRS = new Set([
'.git',
'.next',
'.turbo',
'node_modules',
'dist',
'build',
'coverage',
'out',
'.cache',
]);
const SOURCE_DIRS = [
'app',
'src/app',
'pages',
'src/pages',
'components',
'src/components',
'ui',
'src/ui',
];
const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mdx']);
const KEYWORDS = {
hero: 4,
card: 3,
button: 3,
modal: 5,
dialog: 5,
drawer: 5,
accordion: 5,
tabs: 4,
tab: 4,
list: 3,
grid: 3,
nav: 2,
header: 2,
sidebar: 3,
menu: 4,
toast: 4,
dropdown: 4,
page: 3,
layout: 2,
};
const HOOK_IMPORTS = [
'useReducedMotion',
'useAnimate',
'useInView',
'useScroll',
'useMotionValue',
'useTransform',
'useSpring',
'useVelocity',
'usePathname',
'useSelectedLayoutSegment',
'useSelectedLayoutSegments',
];
const MOTION_APIS = [
'AnimatePresence',
'LayoutGroup',
'MotionConfig',
'Reorder',
'LazyMotion',
'whileInView',
'whileHover',
'whileTap',
'whileFocus',
'layoutId',
'layout',
'useReducedMotion',
'useAnimate',
'useInView',
'useScroll',
];
function parseArgs(argv) {
const args = { root: process.cwd(), limit: 16, help: false };
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === '--help' || token === '-h') {
args.help = true;
} else if (token === '--root') {
args.root = argv[index + 1];
index += 1;
} else if (token === '--limit') {
args.limit = Number.parseInt(argv[index + 1], 10);
index += 1;
} else {
throw new Error(`Unknown argument: ${token}`);
}
}
if (!Number.isFinite(args.limit) || args.limit <= 0) {
throw new Error('--limit must be a positive integer.');
}
return args;
}
function readText(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch {
return '';
}
}
function readJson(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
return null;
}
}
function exists(filePath) {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
}
function isClientComponent(text) {
return /^\s*["']use client["'];?/m.test(text);
}
function findFirstExisting(root, candidates) {
for (const relative of candidates) {
const absolute = path.join(root, relative);
if (exists(absolute)) return relative;
}
return null;
}
function detectRouter(root) {
const hasApp = exists(path.join(root, 'app')) || exists(path.join(root, 'src/app'));
const hasPages = exists(path.join(root, 'pages')) || exists(path.join(root, 'src/pages'));
if (hasApp && hasPages) return 'mixed';
if (hasApp) return 'app-router';
if (hasPages) return 'pages-router';
return 'unknown';
}
function flattenDependencies(pkg) {
const output = {};
if (!pkg || typeof pkg !== 'object') return output;
for (const section of ['dependencies', 'devDependencies', 'peerDependencies']) {
const value = pkg[section];
if (!value || typeof value !== 'object') continue;
for (const [name, version] of Object.entries(value)) {
if (typeof version === 'string') output[name] = version;
}
}
return output;
}
function detectStylingHints(root, deps) {
const hints = {
tailwind: false,
cssModules: false,
styledComponents: Boolean(deps['styled-components']),
emotion: Boolean(deps['@emotion/react'] || deps['@emotion/styled']),
};
if (['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.mjs'].some((name) => exists(path.join(root, name)))) {
hints.tailwind = true;
}
function walk(current) {
let entries = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) {
if (IGNORE_DIRS.has(entry.name)) continue;
walk(absolute);
continue;
}
if (entry.name.endsWith('.module.css') || entry.name.endsWith('.module.scss')) {
hints.cssModules = true;
}
if (hints.cssModules && hints.tailwind) return;
}
}
walk(root);
return hints;
}
function iterSourceFiles(root) {
const files = [];
function walk(start) {
let entries = [];
try {
entries = fs.readdirSync(start, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const absolute = path.join(start, entry.name);
if (entry.isDirectory()) {
if (IGNORE_DIRS.has(entry.name)) continue;
walk(absolute);
continue;
}
if (SOURCE_EXTS.has(path.extname(entry.name))) {
files.push(absolute);
}
}
}
for (const base of SOURCE_DIRS) {
const start = path.join(root, base);
if (exists(start)) walk(start);
}
return files;
}
function analyseFile(root, absolutePath) {
const text = readText(absolutePath);
const relative = path.relative(root, absolutePath).replaceAll(path.sep, '/');
const lowerRelative = relative.toLowerCase();
const imports = {
'motion/react': /from\s+["']motion\/react["']/.test(text),
'motion/react-client': /from\s+["']motion\/react-client["']/.test(text),
'motion/react-m': /from\s+["']motion\/react-m["']/.test(text),
'framer-motion': /from\s+["']framer-motion["']/.test(text),
};
const signals = {
clientComponent: isClientComponent(text),
hasInteractionLogic: ['useState(', 'useReducer(', 'useEffect(', 'useLayoutEffect(', 'useRef(', 'onClick=', 'onPointerDown=', 'onMouseEnter=', 'onMouseLeave=', 'onKeyDown='].some((token) => text.includes(token)),
importsNextImage: text.includes('from "next/image"') || text.includes("from 'next/image'"),
importsNextDynamic: text.includes('from "next/dynamic"') || text.includes("from 'next/dynamic'"),
hasListMap: text.includes('.map('),
hasConditionalRender: text.includes(' ? ') || text.includes('&& <') || text.includes('&& ('),
hasImageComponent: text.includes('<Image'),
hasSerializationRisk: text.includes('onClick') && !isClientComponent(text),
};
const hookHits = HOOK_IMPORTS.filter((name) => text.includes(name));
const motionHits = MOTION_APIS.filter((name) => text.includes(name));
let score = 0;
const reasons = [];
if (/\/page\.(t|j)sx?$/.test(lowerRelative)) {
score += 4;
reasons.push('page component');
}
if (/\/layout\.(t|j)sx?$/.test(lowerRelative)) {
score += 2;
reasons.push('layout component');
}
if (lowerRelative.startsWith('components/') || lowerRelative.includes('/components/') || lowerRelative.startsWith('ui/') || lowerRelative.includes('/ui/')) {
score += 1;
reasons.push('component directory');
}
for (const [word, weight] of Object.entries(KEYWORDS)) {
if (lowerRelative.includes(word)) {
score += weight;
reasons.push(`matches '${word}'`);
}
}
if (signals.clientComponent) {
score += 3;
reasons.push('already client');
}
if (Object.values(imports).some(Boolean)) {
score += 3;
reasons.push('already uses Motion');
}
if (signals.hasInteractionLogic) {
score += 2;
reasons.push('interactive logic');
}
if (signals.hasListMap) {
score += 1;
reasons.push('maps list');
}
if (signals.hasConditionalRender) {
score += 1;
reasons.push('conditional UI');
}
if (signals.importsNextImage) {
reasons.push('next/image present');
}
let recommendedStrategy = 'unknown';
if (imports['motion/react-client']) {
recommendedStrategy = 'keep server-friendly motion/react-client pattern';
} else if (signals.clientComponent || signals.hasInteractionLogic || hookHits.length > 0) {
recommendedStrategy = 'small client leaf using motion/react or existing framer-motion';
} else {
recommendedStrategy = 'candidate for passive motion/react-client or tiny client leaf';
}
return {
path: relative,
score,
reasons: reasons.slice(0, 6),
imports,
signals,
motionHits,
hookHits,
recommendedStrategy,
};
}
function chooseLibrary(deps, importCounter) {
if (deps['framer-motion'] || importCounter['framer-motion'] > 0) {
return {
choice: 'framer-motion',
reason: 'Repository already uses framer-motion. Preserve the import path unless the user explicitly asks to migrate.',
};
}
if (deps.motion || importCounter['motion/react'] > 0 || importCounter['motion/react-client'] > 0) {
return {
choice: 'motion/react',
reason: 'Repository already uses Motion or depends on the motion package.',
};
}
return {
choice: 'motion/react',
reason: 'No existing Motion dependency found. Prefer motion for a new install.',
};
}
function detectPackageManager(root) {
if (exists(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm';
if (exists(path.join(root, 'package-lock.json'))) return 'npm';
if (exists(path.join(root, 'yarn.lock'))) return 'yarn';
if (exists(path.join(root, 'bun.lock')) || exists(path.join(root, 'bun.lockb'))) return 'bun';
return 'unknown';
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
process.stdout.write(`${HELP}
`);
return;
}
const root = path.resolve(args.root);
if (!exists(root)) {
throw new Error(`Root does not exist: ${root}`);
}
const pkgPath = path.join(root, 'package.json');
const pkg = readJson(pkgPath) || {};
const deps = flattenDependencies(pkg);
const router = detectRouter(root);
const stylingHints = detectStylingHints(root, deps);
const fileAnalyses = iterSourceFiles(root).map((filePath) => analyseFile(root, filePath));
const importCounter = {
'motion/react': 0,
'motion/react-client': 0,
'motion/react-m': 0,
'framer-motion': 0,
};
const motionUsageSummary = {};
const warnings = [];
for (const item of fileAnalyses) {
for (const [name, present] of Object.entries(item.imports)) {
if (present) importCounter[name] += 1;
}
for (const hit of item.motionHits) {
motionUsageSummary[hit] = (motionUsageSummary[hit] || 0) + 1;
}
}
const libraryRecommendation = chooseLibrary(deps, importCounter);
if (importCounter['framer-motion'] && (importCounter['motion/react'] || importCounter['motion/react-client'])) {
warnings.push('Mixed framer-motion and motion imports detected. Preserve consistency in the edited scope or migrate intentionally.');
}
if (router === 'app-router') {
for (const item of fileAnalyses) {
if (item.path.endsWith('app/layout.tsx') || item.path.endsWith('src/app/layout.tsx')) {
if (item.signals.clientComponent) {
warnings.push('Root App Router layout is a client component. Be careful not to widen the client boundary further.');
}
}
}
}
if ((importCounter['motion/react'] || importCounter['framer-motion']) && !motionUsageSummary.useReducedMotion && !motionUsageSummary.MotionConfig) {
warnings.push('Motion is present but reduced-motion handling was not detected. Consider whether the edited UI should add it.');
}
if (libraryRecommendation.choice === 'motion/react' && importCounter['motion/react-client'] === 0 && router === 'app-router') {
warnings.push('App Router project detected. Consider motion/react-client for passive cases if the repo already uses the motion package.');
}
const candidateFiles = fileAnalyses
.filter((item) => item.score > 0)
.sort((a, b) => (b.score - a.score) || a.path.localeCompare(b.path))
.slice(0, args.limit);
const result = {
root,
router,
packageJsonFound: exists(pkgPath),
packageManager: detectPackageManager(root),
dependencies: Object.fromEntries(
Object.entries(deps).filter(([name]) => ['next', 'react', 'react-dom', 'motion', 'framer-motion', 'tailwindcss', 'styled-components', '@emotion/react', '@emotion/styled'].includes(name))
),
stylingHints,
libraryRecommendation,
boundaries: {
appLayout: findFirstExisting(root, ['app/layout.tsx', 'app/layout.jsx', 'app/layout.js', 'app/layout.mjs', 'src/app/layout.tsx', 'src/app/layout.jsx', 'src/app/layout.js', 'src/app/layout.mjs']),
appTemplate: findFirstExisting(root, ['app/template.tsx', 'app/template.jsx', 'app/template.js', 'app/template.mjs', 'src/app/template.tsx', 'src/app/template.jsx', 'src/app/template.js', 'src/app/template.mjs']),
pagesApp: findFirstExisting(root, ['pages/_app.tsx', 'pages/_app.jsx', 'pages/_app.js', 'pages/_app.mjs', 'src/pages/_app.tsx', 'src/pages/_app.jsx', 'src/pages/_app.js', 'src/pages/_app.mjs']),
},
importStyleSummary: importCounter,
motionUsageSummary: Object.fromEntries(Object.entries(motionUsageSummary).sort(([a], [b]) => a.localeCompare(b))),
candidateFiles,
warnings,
};
process.stdout.write(`${JSON.stringify(result, null, 2)}
`);
}
try {
main();
} catch (error) {
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
`);
process.stderr.write(`${HELP}
`);
process.exit(1);
}
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const HELP = `Usage: node scripts/check-motion-antipatterns.mjs [--root PATH]
Scan a Next.js repo for common Motion / Framer Motion anti-patterns. Prints JSON to stdout.
Options:
--root PATH Repository root to inspect. Defaults to current working directory.
--help Show this help text.
Examples:
node scripts/check-motion-antipatterns.mjs --root ../my-app
node scripts/check-motion-antipatterns.mjs
`;
const IGNORE_DIRS = new Set([
".git",
".next",
".turbo",
"node_modules",
"dist",
"build",
"coverage",
"out",
".cache",
]);
const SOURCE_DIRS = [
"app",
"src/app",
"pages",
"src/pages",
"components",
"src/components",
"ui",
"src/ui",
];
const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mdx"]);
function parseArgs(argv) {
const args = { root: process.cwd(), help: false };
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === "--help" || token === "-h") {
args.help = true;
} else if (token === "--root") {
args.root = argv[index + 1];
index += 1;
} else {
throw new Error(`Unknown argument: ${token}`);
}
}
return args;
}
function exists(filePath) {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
}
function readText(filePath) {
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return "";
}
}
function iterSourceFiles(root) {
const files = [];
function walk(start) {
let entries = [];
try {
entries = fs.readdirSync(start, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const absolute = path.join(start, entry.name);
if (entry.isDirectory()) {
if (IGNORE_DIRS.has(entry.name)) continue;
walk(absolute);
continue;
}
if (SOURCE_EXTS.has(path.extname(entry.name))) {
files.push(absolute);
}
}
}
for (const base of SOURCE_DIRS) {
const start = path.join(root, base);
if (exists(start)) walk(start);
}
return files;
}
function isClientComponent(text) {
return /^\s*["']use client["'];?/m.test(text);
}
function inAppRouter(relativePath) {
const value = relativePath.replaceAll(path.sep, "/").toLowerCase();
return value.startsWith("app/") || value.startsWith("src/app/");
}
function lineNumberFor(text, matchIndex) {
if (matchIndex < 0) return null;
return text.slice(0, matchIndex).split(/\r?\n/).length;
}
function addIssue(issues, { ruleId, severity, path: filePath, message, line = null }) {
issues.push({ ruleId, severity, path: filePath, line, message });
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
process.stdout.write(`${HELP}\n`);
return;
}
const root = path.resolve(args.root);
if (!exists(root)) {
throw new Error(`Root does not exist: ${root}`);
}
const files = iterSourceFiles(root);
const issues = [];
const importSummary = {
"motion/react": 0,
"motion/react-client": 0,
"motion/react-m": 0,
"framer-motion": 0,
};
let reducedMotionSignals = 0;
for (const absolutePath of files) {
const relativePath = path.relative(root, absolutePath).replaceAll(path.sep, "/");
const text = readText(absolutePath);
if (!text) continue;
const clientComponent = isClientComponent(text);
const imports = {
"motion/react": /from\s+["']motion\/react["']/.test(text),
"motion/react-client": /from\s+["']motion\/react-client["']/.test(text),
"motion/react-m": /from\s+["']motion\/react-m["']/.test(text),
"framer-motion": /from\s+["']framer-motion["']/.test(text),
};
for (const [name, present] of Object.entries(imports)) {
if (present) importSummary[name] += 1;
}
if (text.includes("useReducedMotion") || text.includes("MotionConfig")) {
reducedMotionSignals += 1;
}
if (
inAppRouter(relativePath) &&
!clientComponent &&
(imports["motion/react"] || imports["framer-motion"])
) {
addIssue(issues, {
ruleId: "app-router-server-imports-client-motion",
severity: "error",
path: relativePath,
line: 1,
message: "App Router server file imports motion/react or framer-motion directly. Move the animated part into a client leaf or use motion/react-client for passive cases.",
});
}
if (
(relativePath === "app/layout.tsx" || relativePath === "src/app/layout.tsx") &&
clientComponent
) {
addIssue(issues, {
ruleId: "root-layout-client",
severity: "warning",
path: relativePath,
line: 1,
message: "Root App Router layout is a Client Component. Avoid widening this boundary unless the route truly needs a global client shell.",
});
}
if (
imports["motion/react-client"] &&
(clientComponent ||
/\buse(State|Reducer|Effect|LayoutEffect|Ref)\s*\(/.test(text) ||
/\bon(Click|PointerDown|MouseEnter|MouseLeave|KeyDown)\s*=/.test(text))
) {
addIssue(issues, {
ruleId: "motion-react-client-with-hooks-or-client-directive",
severity: "warning",
path: relativePath,
line: 1,
message: "motion/react-client is intended for passive server-friendly components. This file is client-driven or interactive, so motion/react is the safer fit.",
});
}
if (text.includes("AnimatePresence") && /\bkey=\{(?:index|idx|i)\}/.test(text)) {
addIssue(issues, {
ruleId: "animatepresence-index-key",
severity: "error",
path: relativePath,
line: lineNumberFor(text, text.search(/\bkey=\{(?:index|idx|i)\}/)),
message: "AnimatePresence child appears to use an index-based key. Use a stable item identifier instead.",
});
}
if (text.includes("AnimatePresence") && !/key=/.test(text)) {
addIssue(issues, {
ruleId: "animatepresence-missing-key",
severity: "warning",
path: relativePath,
line: lineNumberFor(text, text.indexOf("AnimatePresence")),
message: "AnimatePresence detected without an obvious key. Verify the direct child uses a stable key when presence depends on switching children.",
});
}
if (
/(?:animate|initial|exit)\s*=\s*\{\{[\s\S]{0,200}\b(?:top|left|right|bottom)\b[\s\S]{0,200}\}\}/m.test(text)
) {
addIssue(issues, {
ruleId: "top-left-animation",
severity: "warning",
path: relativePath,
line: lineNumberFor(text, text.search(/(?:animate|initial|exit)\s*=\s*\{\{/m)),
message: "Detected top/left/right/bottom animation. Prefer transform-based x/y motion for better performance.",
});
}
if (
text.includes("layoutId") &&
text.includes(".map(") &&
(!text.includes("LayoutGroup") || !/LayoutGroup[^>]*\sid=/.test(text))
) {
addIssue(issues, {
ruleId: "layoutid-without-layoutgroup-id",
severity: "warning",
path: relativePath,
line: lineNumberFor(text, text.indexOf("layoutId")),
message: "layoutId appears inside a repeated context without a LayoutGroup id namespace. Repeated widgets can collide.",
});
}
if (/^\s{2,}(?:const|let|var)\s+\w+\s*=\s*motion\.create\(/m.test(text)) {
addIssue(issues, {
ruleId: "motion-create-in-render",
severity: "warning",
path: relativePath,
line: lineNumberFor(text, text.search(/^\s{2,}(?:const|let|var)\s+\w+\s*=\s*motion\.create\(/m)),
message: "motion.create() appears inside an indented block, which usually means inside render. Hoist it to module scope.",
});
}
if (text.includes("Reorder.Item") && !text.includes("Reorder.Group")) {
addIssue(issues, {
ruleId: "reorder-item-without-group",
severity: "error",
path: relativePath,
line: lineNumberFor(text, text.indexOf("Reorder.Item")),
message: "Reorder.Item detected without Reorder.Group in the same file. Verify the item sits inside a matching group.",
});
}
}
if (
importSummary["framer-motion"] > 0 &&
(importSummary["motion/react"] > 0 || importSummary["motion/react-client"] > 0 || importSummary["motion/react-m"] > 0)
) {
addIssue(issues, {
ruleId: "mixed-import-packages",
severity: "error",
path: "(repo)",
line: null,
message: "Both framer-motion and motion import styles were detected. Preserve one package strategy per edited scope unless doing an explicit migration.",
});
}
const motionImportCount = Object.values(importSummary).reduce((sum, value) => sum + value, 0);
if (motionImportCount > 0 && reducedMotionSignals === 0) {
addIssue(issues, {
ruleId: "reduced-motion-missing",
severity: "warning",
path: "(repo)",
line: null,
message: "Motion imports were found but no useReducedMotion or MotionConfig reduced-motion handling was detected.",
});
}
const summary = {
errors: issues.filter((issue) => issue.severity === "error").length,
warnings: issues.filter((issue) => issue.severity === "warning").length,
importSummary,
reducedMotionSignals,
};
const result = { root, summary, issues };
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}
try {
main();
} catch (error) {
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
process.stderr.write(`${HELP}\n`);
process.exit(1);
}
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const HELP = `Usage: node scripts/inspect-motion-target.mjs FILE [--root PATH]
Inspect one target file and emit Motion / Next.js editing guidance as JSON.
Arguments:
FILE Target file to inspect.
Options:
--root PATH Repository root used for relative path output. Defaults to current working directory.
--help Show this help text.
Examples:
node scripts/inspect-motion-target.mjs app/page.tsx --root ../my-app
node scripts/inspect-motion-target.mjs src/components/hero.tsx
`;
function parseArgs(argv) {
const args = { file: "", root: process.cwd(), help: false };
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === "--help" || token === "-h") {
args.help = true;
} else if (token === "--root") {
args.root = argv[index + 1];
index += 1;
} else if (!args.file) {
args.file = token;
} else {
throw new Error(`Unknown argument: ${token}`);
}
}
if (!args.help && !args.file) {
throw new Error("A target file is required.");
}
return args;
}
function readText(filePath) {
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return "";
}
}
function isClientComponent(text) {
return /^\s*["']use client["'];?/m.test(text);
}
function detectRouterContext(relativePath) {
const value = relativePath.replaceAll('\\', '/').toLowerCase();
if (value.startsWith('app/') || value.includes('/app/') || value.startsWith('src/app/')) {
return 'app-router';
}
if (value.startsWith('pages/') || value.includes('/pages/') || value.startsWith('src/pages/')) {
return 'pages-router';
}
return 'shared';
}
function relativeOrAbsolute(root, target) {
const relative = path.relative(root, target).replaceAll(path.sep, '/');
return relative.startsWith('..') ? target : relative;
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
process.stdout.write(`${HELP}
`);
return;
}
const root = path.resolve(args.root);
const target = path.resolve(root, args.file);
if (!fs.existsSync(target)) {
throw new Error(`File not found: ${target}`);
}
const text = readText(target);
if (!text) {
throw new Error(`Could not read file: ${target}`);
}
const relPath = relativeOrAbsolute(root, target);
const routerContext = detectRouterContext(relPath);
const clientComponent = isClientComponent(text);
const imports = {
'motion/react': /from\s+["']motion\/react["']/.test(text),
'motion/react-client': /from\s+["']motion\/react-client["']/.test(text),
'motion/react-m': /from\s+["']motion\/react-m["']/.test(text),
'framer-motion': /from\s+["']framer-motion["']/.test(text),
};
const hooks = [
'useReducedMotion',
'useAnimate',
'useInView',
'useScroll',
'useMotionValue',
'useTransform',
'useSpring',
'useVelocity',
'usePathname',
'useSelectedLayoutSegment',
'useSelectedLayoutSegments',
];
const hookHits = hooks.filter((name) => text.includes(name));
const interactionSignals = [
'useState(',
'useReducer(',
'useEffect(',
'useLayoutEffect(',
'useRef(',
'onClick=',
'onPointerDown=',
'onMouseEnter=',
'onKeyDown=',
];
const interaction = interactionSignals.some((token) => text.includes(token));
const patternHints = {
presence: ['AnimatePresence', 'exit=', 'modal', 'dialog', 'drawer', 'dropdown', 'toast'].some((token) => text.includes(token)),
stagger: ['variants', 'staggerChildren', 'delayChildren', '.map('].some((token) => text.includes(token)),
'scroll-reveal': ['whileInView', 'useInView', 'viewport={{'].some((token) => text.includes(token)),
'scroll-linked': ['useScroll', 'scrollYProgress', 'scrollXProgress'].some((token) => text.includes(token)),
layout: ['layout', 'layoutId', 'LayoutGroup', 'accordion', 'tabs'].some((token) => text.includes(token)),
reorder: ['Reorder.', 'onReorder', 'useDragControls'].some((token) => text.includes(token)),
microinteraction: ['whileHover', 'whileTap', 'whileFocus', '<button', '<a ', 'next/link'].some((token) => text.includes(token)),
};
const warnings = [];
const recommendations = [];
if (routerContext === 'app-router' && !clientComponent && imports['framer-motion']) {
warnings.push('App Router server-friendly file imports framer-motion directly. Prefer a tiny client leaf unless the file is already client-side.');
}
if (text.includes('<Image') && (!text.includes(' width=') && !text.includes(' fill'))) {
warnings.push('next/image usage detected. Check that layout space is still preserved when animating.');
}
if (text.includes('AnimatePresence') && !text.includes('key=')) {
warnings.push('AnimatePresence detected but no obvious key was found. Verify direct-child stable keys manually.');
}
if (text.includes('layoutId') && !text.includes('LayoutGroup')) {
recommendations.push('If this shared element appears in repeated widgets, consider LayoutGroup id to isolate layoutId scope.');
}
if (interaction || hookHits.length > 0 || clientComponent) {
recommendations.push('This file is already interactive or hook-driven. A small client-component Motion pattern is the most natural fit.');
} else if (routerContext === 'app-router') {
recommendations.push('This App Router file might be a candidate for motion/react-client if the requested effect is passive and hook-free.');
} else {
recommendations.push('This file can usually take direct motion/react or framer-motion usage without extra boundary work.');
}
if (patternHints.microinteraction) {
recommendations.push('Microinteraction signals detected. Prefer whileHover plus whileTap plus whileFocus with restrained values.');
}
if (patternHints.stagger) {
recommendations.push('List or variant signals detected. Prefer parent-controlled stagger rather than bespoke timing on every child.');
}
if (patternHints.presence) {
recommendations.push('Presence signals detected. Use AnimatePresence with direct children and stable keys.');
}
if (patternHints.layout) {
recommendations.push('Layout signals detected. Start with layout before manual size choreography.');
}
if (patternHints['scroll-linked']) {
recommendations.push('Scroll-linked signals detected. Keep useScroll only if the motion genuinely needs to track scroll continuously.');
}
let recommendedBoundary = 'local-motion-edit';
if (clientComponent) {
recommendedBoundary = 'already-client';
} else if (
routerContext === 'app-router' &&
hookHits.length === 0 &&
!interaction &&
!patternHints.presence &&
!patternHints.reorder &&
!patternHints['scroll-linked']
) {
recommendedBoundary = 'server-friendly-motion-react-client-candidate';
} else if (routerContext === 'app-router') {
recommendedBoundary = 'small-client-leaf';
}
let preferredImportPath = 'inherit-from-repo';
if (imports['framer-motion']) {
preferredImportPath = 'framer-motion';
} else if (imports['motion/react'] || imports['motion/react-client'] || imports['motion/react-m']) {
preferredImportPath = 'motion/react';
}
const result = {
path: relPath,
routerContext,
clientComponent,
imports,
hookHits,
interactionSignalsPresent: interaction,
patternHints,
recommendedBoundary,
preferredImportPath,
warnings,
recommendations,
};
process.stdout.write(`${JSON.stringify(result, null, 2)}
`);
}
try {
main();
} catch (error) {
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
`);
process.stderr.write(`${HELP}
`);
process.exit(1);
}
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const HELP = `Usage: node scripts/plan-motion-change.mjs --root PATH --target FILE --task "TASK"
Combine repo audit + target inspection + task wording into a structured Motion plan.
Required options:
--root PATH Repository root to inspect.
--target FILE Target file to inspect, relative to --root or absolute.
--task TEXT The requested animation task.
Optional:
--help Show this help text.
Examples:
node scripts/plan-motion-change.mjs --root ../my-app --target app/page.tsx --task "Add a subtle reveal to these cards"
node scripts/plan-motion-change.mjs --root . --target pages/_app.tsx --task "Add route transitions between blog pages"
`;
function parseArgs(argv) {
const args = { root: "", target: "", task: "", help: false };
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === "--help" || token === "-h") {
args.help = true;
} else if (token === "--root") {
args.root = argv[index + 1] || "";
index += 1;
} else if (token === "--target") {
args.target = argv[index + 1] || "";
index += 1;
} else if (token === "--task") {
args.task = argv[index + 1] || "";
index += 1;
} else {
throw new Error(`Unknown argument: ${token}`);
}
}
if (!args.help && (!args.root || !args.target || !args.task)) {
throw new Error("--root, --target, and --task are required.");
}
return args;
}
function runJson(scriptPath, args) {
const result = spawnSync(process.execPath, [scriptPath, ...args], {
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(result.stderr?.trim() || `Script failed: ${scriptPath}`);
}
try {
return JSON.parse(result.stdout);
} catch (error) {
throw new Error(`Could not parse JSON from ${scriptPath}: ${error instanceof Error ? error.message : String(error)}`);
}
}
function hasAny(text, patterns) {
return patterns.some((pattern) => pattern.test(text));
}
function inferTaskIntents(task) {
const value = task.toLowerCase();
const checks = [
{ intent: "migration", patterns: [/\bmigrat(e|ion|ing)\b/, /\bswitch\b.*\bframer[- ]motion\b/, /\bswap imports\b/] },
{ intent: "route-transition", patterns: [/\broute transitions?\b/, /\bpage transitions?\b/, /\broute-content transition\b/, /\bbetween routes\b/, /\bbetween pages\b/, /\btransition\b.*\bbetween\b.*\bpages?\b/, /\bnavigation\b/, /\bpathname\b/] },
{ intent: "presence", patterns: [/\bmodal\b/, /\bdrawer\b/, /\bdialog\b/, /\bdropdown\b/, /\bpopover\b/, /\btoast\b/, /\bexit animations?\b/, /\bopen(?:\/| )close\b/] },
{ intent: "shared-layout", patterns: [/\bshared element\b/, /\bshared layout\b/, /\blayoutid\b/, /\btab underline\b/, /\bunderline\b/, /\bselected pill\b/] },
{ intent: "layout", patterns: [/\baccordion\b/, /\bexpand\b/, /\bcollapse\b/, /\blayout animation\b/, /\breflow\b/] },
{ intent: "reorder", patterns: [/\breorder\b/, /\bsortable\b/, /\bdrag to reorder\b/, /\bdrag-and-drop\b/, /\bdrag and drop\b/] },
{ intent: "scroll-linked", patterns: [/\bparallax\b/, /\bscroll-linked\b/, /\bscroll linked\b/, /\bscroll progress\b/, /\bprogress bar\b/] },
{ intent: "scroll-reveal", patterns: [/\bon scroll\b/, /\bin view\b/, /\binto view\b/, /\bwhileinview\b/, /\bviewport\b/, /\bscroll reveal\b/] },
{ intent: "design-system", patterns: [/\bdesign system\b/, /\bdesign-system\b/, /\bforwardref\b/, /\bforward ref\b/, /\bwrapper div\b/, /\bwrapper element\b/, /\bmotion\.create\b/] },
{ intent: "microinteraction", patterns: [/\bhover\b/, /\btap\b/, /\bfocus\b/, /\bbutton\b/, /\blink\b/, /\bcard\b/, /\bfeel better\b/, /\bsnappier\b/, /\bmicro-?interaction\b/, /\bcta\b/] },
{ intent: "mount-reveal", patterns: [/\bfirst render\b/, /\bon load\b/, /\bmount\b/, /\bfade in\b/, /\bfade-in\b/, /\breveal\b/, /\bentrance\b/] },
{ intent: "performance", patterns: [/\bbundle\b/, /\bjank\b/, /\bperformance\b/, /\bperf\b/, /\blazymotion\b/, /\bdomanimation\b/, /\bdommax\b/] },
{ intent: "debugging", patterns: [/\bbroken\b/, /\bnot working\b/, /\bbug\b/, /\bdebug\b/, /\bhydration\b/, /\bmismatch\b/, /\bwhy\b.*\bexit\b/, /\bfix\b/] },
{ intent: "accessibility", patterns: [/\breduced motion\b/, /\baccessibility\b/, /\bprefers-reduced-motion\b/] },
];
const intents = checks
.filter((check) => hasAny(value, check.patterns))
.map((check) => check.intent);
if (intents.length === 0) intents.push("general-motion-edit");
return intents;
}
function choosePackageStrategy(audit, intents) {
const deps = audit.dependencies || {};
const imports = audit.importStyleSummary || {};
const hasMotion = Boolean(deps.motion || imports["motion/react"] || imports["motion/react-client"] || imports["motion/react-m"]);
const hasFramer = Boolean(deps["framer-motion"] || imports["framer-motion"]);
if (intents.includes("migration")) return "explicit-migration";
if (hasMotion && hasFramer) return "mixed-repo-preserve-scope";
if (hasFramer) return "keep-framer-motion";
if (hasMotion) return "use-motion";
return "install-motion";
}
function fallbackPatternFromTarget(target) {
if (target.patternHints?.presence) return "presence";
if (target.patternHints?.["scroll-linked"]) return "scroll-linked";
if (target.patternHints?.["scroll-reveal"]) return "scroll-reveal";
if (target.patternHints?.reorder) return "reorder";
if (target.patternHints?.layout) return "layout";
if (target.patternHints?.microinteraction) return "microinteraction";
return "mount-reveal";
}
function choosePattern(intents, target, audit) {
if (intents.includes("migration")) return "migration";
if (intents.includes("route-transition")) {
return audit.router === "pages-router" ? "pages-router-route-transition" : "route-transition-shell";
}
if (intents.includes("presence")) return "presence";
if (intents.includes("shared-layout")) return "shared-layout";
if (intents.includes("design-system")) return "design-system-motion-create";
if (intents.includes("reorder")) return "reorder";
if (intents.includes("scroll-linked")) return "scroll-linked";
if (intents.includes("scroll-reveal")) return "scroll-reveal";
if (intents.includes("layout")) return "layout";
if (intents.includes("mount-reveal")) return "mount-reveal";
if (intents.includes("microinteraction")) return "microinteraction";
return fallbackPatternFromTarget(target);
}
function chooseBoundary(pattern, target, audit, packageStrategy) {
if (pattern === "route-transition-shell") return "persistent-route-shell-under-layout";
if (pattern === "pages-router-route-transition") return "pages-app-presence-wrapper";
if (pattern === "migration") return "explicit-migration-scope";
if (target.clientComponent) return "already-client";
if (audit.router === "app-router") {
if ([
"presence",
"shared-layout",
"layout",
"reorder",
"scroll-linked",
"design-system-motion-create",
].includes(pattern)) {
return "small-client-leaf";
}
if (
["mount-reveal", "microinteraction", "scroll-reveal"].includes(pattern) &&
packageStrategy !== "keep-framer-motion" &&
!target.interactionSignalsPresent &&
(target.hookHits || []).length === 0
) {
return "server-friendly-motion-react-client-candidate";
}
return "small-client-leaf";
}
return "local-motion-edit";
}
function chooseImportPath(packageStrategy, boundary) {
if (packageStrategy === "keep-framer-motion") return "framer-motion";
if (packageStrategy === "mixed-repo-preserve-scope") return "inherit-from-edited-scope";
if (packageStrategy === "explicit-migration") return "motion/react";
if (boundary === "server-friendly-motion-react-client-candidate") return "motion/react-client";
return "motion/react";
}
function chooseReducedMotionPlan(pattern) {
if (pattern === "scroll-linked") {
return "Disable parallax-style travel for reduced-motion users and fall back to static or opacity-only state.";
}
if (pattern === "route-transition-shell" || pattern === "pages-router-route-transition") {
return "Keep route transitions to opacity plus very small travel, or disable travel entirely for reduced-motion users.";
}
if (pattern === "microinteraction" || pattern === "design-system-motion-create") {
return "Retain feedback for reduced-motion users, but prefer opacity or colour changes over noticeable movement.";
}
if (pattern === "shared-layout") {
return "Keep state indication clear even when movement is reduced; the active indicator can fade rather than travel dramatically.";
}
return "Use MotionConfig reducedMotion=\"user\" or useReducedMotion(), and switch large travel to opacity-only where appropriate.";
}
function choosePerformancePlan(pattern, boundary) {
if (boundary === "server-friendly-motion-react-client-candidate") {
return "Keep the file server-friendly with motion/react-client and animate only transform and opacity on the existing root element.";
}
if (pattern === "route-transition-shell") {
return "Mount a tiny client shell under the stable layout instead of making the root layout itself a Client Component.";
}
if (pattern === "pages-router-route-transition") {
return "Keep the change in pages/_app.tsx and avoid introducing a broader provider or route-wide animation system elsewhere.";
}
if (pattern === "design-system-motion-create") {
return "Use motion.create() outside render so the wrapper is stable and avoid extra wrapper elements that can disturb layout or refs.";
}
if (pattern === "shared-layout") {
return "Prefer layoutId plus LayoutGroup id over manual DOM measurement or coordinate syncing.";
}
return "Keep the client boundary as small as possible and prefer transform plus opacity over paint-heavy properties.";
}
function chooseFirstSteps(pattern, target, audit, importPath) {
switch (pattern) {
case "route-transition-shell":
return [
`Create a small client shell (for example components/motion/route-transition-shell.tsx) that imports from ${importPath}.`,
`Render that shell from ${audit.boundaries?.appLayout || "app/layout.tsx"} without marking the layout itself as client.`,
"Key the routed content by pathname and keep AnimatePresence mounted with initial={false}.",
];
case "pages-router-route-transition":
return [
`Edit ${audit.boundaries?.pagesApp || "pages/_app.tsx"} and keep the repo on the existing import path.`,
"Wrap the routed component with AnimatePresence and key it by router.asPath when dynamic param changes should animate.",
"Use a restrained opacity plus y transition and avoid route-wide bounce.",
];
case "presence":
return [
`Keep the change in ${target.path} if possible and import from ${importPath}.`,
"Wrap the conditional child in AnimatePresence with a direct child and stable key when needed.",
"Animate overlay opacity and panel opacity/y separately, with an opacity-only reduced-motion fallback.",
];
case "shared-layout":
return [
`Edit ${target.path} and import LayoutGroup plus motion from ${importPath}.`,
"Use layoutId for the moving indicator and namespace repeated widgets with LayoutGroup id.",
"Prefer layout animation over manual coordinate math.",
];
case "design-system-motion-create":
return [
`Edit ${target.path} and keep the wrapper in the existing component family.`,
"Create the Motion-wrapped component with motion.create() outside render.",
"Preserve ref forwarding and apply small whileHover/whileTap/whileFocus values.",
];
case "mount-reveal":
return [
`Turn the existing root element in ${target.path} into a Motion element rather than adding a wrapper.`,
`Use ${importPath} with a small initial opacity/y reveal.`,
"Keep reduced-motion handling local and avoid widening the client boundary.",
];
case "scroll-reveal":
return [
`Turn the existing root element in ${target.path} into a Motion element rather than adding a wrapper.`,
`Use ${importPath} with whileInView and viewport={{ once: true }} unless repeated replay is required.`,
"If the scroll container is not the window, set viewport.root explicitly.",
];
case "microinteraction":
return [
`Edit ${target.path} and use ${importPath} on the existing interactive root element.`,
"Apply whileHover plus whileTap plus whileFocus with restrained values.",
"Avoid wrappers that could break refs, spacing, or CSS selectors.",
];
case "reorder":
return [
`Keep the reorder work in ${target.path} and import Reorder from ${importPath}.`,
"Use Reorder.Group and Reorder.Item together and update state from onReorder.",
"Only use this for simple, single-column reorder interactions.",
];
case "layout":
return [
`Edit ${target.path} and start with layout before manual size choreography.`,
"If content distorts, add layout to affected children or try layout=\"position\".",
"Use a spring for stateful layout movement rather than stacking reveal effects on top.",
];
case "migration":
return [
"Plan the migration as an explicit package + import-path change, not as incidental cleanup inside another small animation task.",
"Swap imports consistently within the migration scope and verify there is no mixed-package residue afterward.",
"Retest reduced motion, route transitions, and shared-layout behaviours after the import-path change.",
];
default:
return [
`Edit ${target.path} and prefer the smallest possible Motion change.`,
"Keep imports consistent with the repo choice and avoid new global providers.",
"Verify reduced motion and build stability before finishing.",
];
}
}
function chooseValidation(pattern, boundary) {
const checks = [
"No mixed motion/framer-motion imports in the edited scope",
"Reduced motion behaves intentionally",
"Transform/opacity are favoured over paint-heavy properties",
];
if (boundary === "persistent-route-shell-under-layout") {
checks.push("AnimatePresence stays mounted across route changes");
checks.push("The root App Router layout remains server-side");
}
if (boundary === "pages-app-presence-wrapper") {
checks.push("pages/_app.tsx keys routed children by a value that actually changes");
}
if (pattern === "presence") {
checks.push("Exit animations use direct children and stable keys");
}
if (pattern === "shared-layout") {
checks.push("Repeated widgets use LayoutGroup id when layoutId is reused");
}
if (pattern === "design-system-motion-create") {
checks.push("motion.create() is outside render and refs still work");
}
if (pattern === "scroll-reveal") {
checks.push("The viewport root is correct for non-window scrollers");
}
if (pattern === "reorder") {
checks.push("Reorder.Item remains inside Reorder.Group");
}
return checks;
}
function likelyFilesToChange(pattern, target, audit) {
if (pattern === "route-transition-shell") {
return [
audit.boundaries?.appLayout || "app/layout.tsx",
"components/motion/route-transition-shell.tsx",
];
}
if (pattern === "pages-router-route-transition") {
return [audit.boundaries?.pagesApp || "pages/_app.tsx"];
}
return [target.path];
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
process.stdout.write(`${HELP}\n`);
return;
}
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const auditPath = path.join(scriptDir, "audit-nextjs-motion.mjs");
const inspectPath = path.join(scriptDir, "inspect-motion-target.mjs");
const root = path.resolve(args.root);
const targetArg = args.target;
const task = args.task.trim();
const audit = runJson(auditPath, ["--root", root, "--limit", "20"]);
const inspect = runJson(inspectPath, [targetArg, "--root", root]);
const taskIntents = inferTaskIntents(task);
const packageStrategy = choosePackageStrategy(audit, taskIntents);
const recommendedPattern = choosePattern(taskIntents, inspect, audit);
const recommendedBoundary = chooseBoundary(recommendedPattern, inspect, audit, packageStrategy);
const recommendedImportPath = chooseImportPath(packageStrategy, recommendedBoundary);
const reducedMotionPlan = chooseReducedMotionPlan(recommendedPattern);
const performancePlan = choosePerformancePlan(recommendedPattern, recommendedBoundary);
const firstSteps = chooseFirstSteps(recommendedPattern, inspect, audit, recommendedImportPath);
const validation = chooseValidation(recommendedPattern, recommendedBoundary);
const warnings = [...(audit.warnings || []), ...(inspect.warnings || [])];
const result = {
root,
task,
taskIntents,
repo: {
router: audit.router,
packageManager: audit.packageManager,
dependencies: audit.dependencies,
libraryRecommendation: audit.libraryRecommendation,
boundaries: audit.boundaries,
candidateFiles: audit.candidateFiles,
},
target: inspect,
decision: {
packageStrategy,
recommendedImportPath,
recommendedBoundary,
recommendedPattern,
reducedMotionPlan,
performancePlan,
likelyFilesToChange: likelyFilesToChange(recommendedPattern, inspect, audit),
firstSteps,
validation,
},
warnings,
};
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}
try {
main();
} catch (error) {
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
process.stderr.write(`${HELP}\n`);
process.exit(1);
}