← All posts
·15 min read

Claude Code with Framer Motion: Animations That Ship

Claude CodeFramer MotionReactAnimation
Claude Code with Framer Motion: animations that ship

Why Framer Motion without CLAUDE.md produces janky animations

Framer Motion is the most ergonomic animation library available to React developers in 2026. The API is small, the TypeScript types are accurate, and a basic fade-in is two lines of JSX. The problem is that Claude Code does not know which of those two lines lock you into anti-patterns that become unmaintainable at scale. Without explicit constraints, Claude generates animations that work in the sense that elements move, but produce code that conflates styling with motion, breaks on layout shifts, ignores reduced-motion preferences, and animates the wrong properties for performance.

The most common Claude defaults that hurt animation quality: inline animate objects on every component instead of named variants, missing AnimatePresence wrappers for exit animations, omitted mode="wait" when a single child is being replaced, animating width and height instead of transform, no respect for prefers-reduced-motion, and the dreaded missing key prop on AnimatePresence children that silently disables exit animations entirely. The TypeScript surface does not catch any of these because they are all runtime behaviour, not type contracts.

This guide covers the CLAUDE.md configuration that locks Claude Code into Framer Motion's correct model: variants as the unit of composition, transform-based properties, accessibility-first motion, and the gesture and layout primitives that make complex interactions readable. If you are building a Next.js application and want animations that work with React Server Components, Claude Code with Next.js covers the client boundary rules that Framer Motion sits behind. For component libraries where animations need to compose with design tokens, Claude Code with Tailwind shows how to pair motion values with utility classes.

The Framer Motion CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Framer Motion integration it needs to declare: the package version, the component conventions, the variant-first authoring pattern, the exit animation rules, the reduced-motion policy, and the hard rules that block the mistakes Claude makes most often.

# Framer Motion rules

## Stack
- framer-motion ^11.x, React 18.x or 19.x, TypeScript 5.x strict
- Next.js 14.x or 15.x (with "use client" boundaries for motion components)
- All motion components live in src/components/motion/

## Authoring style
- ALWAYS use named variants, NEVER inline animate={{ ... }} objects
- Variants live in the same file as the component, named const VARIANT_NAME
- For shared animation vocabularies, src/lib/motion/variants.ts exports
  reusable variant objects (fadeIn, slideUp, scaleIn, stagger)

## Properties to animate (PERFORMANCE)
- ALWAYS animate: transform (x, y, scale, rotate), opacity
- NEVER animate (paint or layout triggering): width, height, top, left, margin,
  padding, background-color (use opacity overlay instead)
- For layout changes, use the layout prop or layoutId, NOT manual width/height

## AnimatePresence (EXIT ANIMATIONS)
- Wrap conditionally rendered components in <AnimatePresence>
- EVERY child of AnimatePresence MUST have a unique key prop
- For single-child swaps, ALWAYS set mode="wait"
- For initial mount silence, set initial={false} on AnimatePresence

## Reduced motion
- ALWAYS wrap entry points with MotionConfig reducedMotion="user"
- For per-component overrides, useReducedMotion() hook
- Reduced-motion paths SHOULD fade only (opacity), not move

## Hard rules
- NEVER inline animate={{ x: 100 }} as the only animation source on a reusable component
- NEVER omit the key prop on AnimatePresence children
- NEVER animate width/height/top/left for performance-sensitive transitions
- NEVER use motion.div for non-animating elements (wasted reconciliation)
- NEVER set transition: { duration: 0 } as a workaround, use initial={false}
- ALWAYS pair layoutId with the same layoutId on the destination element

Three rules here prevent the majority of production issues Claude generates without them.

The variants rule is the most impactful for maintainability. Inline animate={{ x: 100, opacity: 1 }} objects create new prop references on every render, prevent reuse across components, and bury motion logic inside JSX where it is hard to refactor. Named variants like { visible: { opacity: 1 }, hidden: { opacity: 0 } } separate motion definition from motion application, compose with whileHover, whileTap, and whileInView, and read cleanly at scale.

The transform-only rule is the most impactful for performance. Animating width triggers layout recalculation on every frame, which cascades to every descendant. Animating transform: scaleX() runs entirely on the compositor and stays at 60fps regardless of subtree complexity. Claude defaults to whatever property the developer mentions, so a request for "expanding card" tends to produce width animation. The CLAUDE.md rule redirects every animation to transform.

The AnimatePresence key rule prevents the single most common Framer Motion bug. Without a unique key, React reuses the same DOM node when the children change, and AnimatePresence cannot detect the exit because the same component is still mounted. The animation silently does nothing. Claude generates this bug constantly because the key prop looks redundant when there is only one child in the tree.

Install and provider setup

Install Framer Motion:

npm i framer-motion

For Next.js App Router, every motion component needs a client boundary. Add the provider at the root of your app:

// src/app/providers.tsx
"use client";

import { MotionConfig, LazyMotion, domAnimation } from "framer-motion";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <LazyMotion features={domAnimation}>
      <MotionConfig
        reducedMotion="user"
        transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
      >
        {children}
      </MotionConfig>
    </LazyMotion>
  );
}

LazyMotion lazy-loads the animation features, reducing bundle size from 40kb to under 10kb on initial paint. MotionConfig with reducedMotion="user" makes the entire app respect the operating system's reduced motion preference automatically.

Wrap your root layout:

// src/app/layout.tsx
import { Providers } from "./providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Add the provider pattern to CLAUDE.md:

## Provider setup (ENFORCE)
- src/app/providers.tsx wraps the app in LazyMotion + MotionConfig
- LazyMotion uses domAnimation (not domMax) unless 3D transforms are needed
- MotionConfig sets reducedMotion="user" globally
- Default transition: duration 0.3, easing [0.22, 1, 0.36, 1]
- Claude MUST NOT import { motion } without ensuring this provider exists

Variants pattern

The variant pattern separates animation definition from application. A typical reusable card:

// src/components/motion/AnimatedCard.tsx
"use client";

import { motion, Variants } from "framer-motion";

const cardVariants: Variants = {
  hidden: {
    opacity: 0,
    y: 20,
  },
  visible: {
    opacity: 1,
    y: 0,
    transition: {
      duration: 0.4,
      ease: [0.22, 1, 0.36, 1],
    },
  },
  exit: {
    opacity: 0,
    y: -20,
    transition: { duration: 0.2 },
  },
};

interface AnimatedCardProps {
  children: React.ReactNode;
  delay?: number;
}

export function AnimatedCard({ children, delay = 0 }: AnimatedCardProps) {
  return (
    <motion.div
      variants={cardVariants}
      initial="hidden"
      animate="visible"
      exit="exit"
      transition={{ delay }}
      className="rounded-lg border bg-card p-6"
    >
      {children}
    </motion.div>
  );
}

Three behaviours are now available without changing the component: initial="hidden" plays the entry animation on mount, animate="visible" settles into the resting state, and exit="exit" plays when the component unmounts inside an AnimatePresence.

A shared variants library makes this composable across the app:

// src/lib/motion/variants.ts
import { Variants } from "framer-motion";

export const fadeIn: Variants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1, transition: { duration: 0.3 } },
};

export const slideUp: Variants = {
  hidden: { opacity: 0, y: 20 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.4, ease: [0.22, 1, 0.36, 1] },
  },
};

export const stagger: Variants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.08,
      delayChildren: 0.1,
    },
  },
};

export const scaleIn: Variants = {
  hidden: { opacity: 0, scale: 0.95 },
  visible: {
    opacity: 1,
    scale: 1,
    transition: { duration: 0.3, ease: [0.22, 1, 0.36, 1] },
  },
};

Staggered children in a list:

"use client";

import { motion } from "framer-motion";
import { stagger, slideUp } from "@/lib/motion/variants";

export function AnimatedList({ items }: { items: string[] }) {
  return (
    <motion.ul variants={stagger} initial="hidden" animate="visible">
      {items.map((item) => (
        <motion.li key={item} variants={slideUp}>
          {item}
        </motion.li>
      ))}
    </motion.ul>
  );
}

Each motion.li inherits the visible variant from its parent and runs its own slideUp definition. The staggerChildren: 0.08 on the parent staggers the start times. This pattern reads cleanly and produces a polished cascade with no animation logic in the JSX itself.

AnimatePresence and exit animations

Exit animations require AnimatePresence as the parent of any component that conditionally renders. The most common bug Claude generates is forgetting the key prop or the mode="wait" setting.

"use client";

import { useState } from "framer-motion";
import { AnimatePresence, motion } from "framer-motion";

const tabs = [
  { id: "overview", label: "Overview", content: "Overview content..." },
  { id: "settings", label: "Settings", content: "Settings content..." },
  { id: "billing", label: "Billing", content: "Billing content..." },
];

export function Tabs() {
  const [activeTab, setActiveTab] = useState(tabs[0].id);
  const active = tabs.find((t) => t.id === activeTab)!;

  return (
    <div>
      <div className="flex gap-2 border-b">
        {tabs.map((tab) => (
          <button
            key={tab.id}
            onClick={() => setActiveTab(tab.id)}
            className={activeTab === tab.id ? "border-b-2 border-black" : ""}
          >
            {tab.label}
          </button>
        ))}
      </div>

      <AnimatePresence mode="wait" initial={false}>
        <motion.div
          key={active.id}
          initial={{ opacity: 0, y: 8 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -8 }}
          transition={{ duration: 0.2 }}
          className="p-6"
        >
          {active.content}
        </motion.div>
      </AnimatePresence>
    </div>
  );
}

Three things make this work:

  1. key={active.id} on the motion.div tells React this is a new element when the active tab changes. Without the key, React reuses the same DOM node and AnimatePresence sees no unmount event.
  2. mode="wait" delays the entry of the new tab until the previous tab's exit animation completes. Without mode="wait", both animations run simultaneously and visually overlap.
  3. initial={false} on AnimatePresence suppresses the entry animation on the very first mount. Without it, the first tab appears with an entry animation that often is not desired.

Add an AnimatePresence section to CLAUDE.md:

## AnimatePresence patterns

- Every conditionally rendered animated component needs a unique key
- Single-child swap (tabs, modals, accordions): mode="wait"
- Multi-child lists: no mode setting needed
- Suppress mount animation: initial={false} on AnimatePresence
- The key prop drives the unmount detection, NEVER omit it
- For lists, the key on motion children should be the data ID, not the index

Layout animations

The layout prop and layoutId enable shared element transitions and automatic layout animations. They are the most powerful Framer Motion feature and the one Claude is most likely to use incorrectly.

A grid that animates when items reorder:

"use client";

import { motion } from "framer-motion";

interface GridItem {
  id: string;
  label: string;
}

export function ReorderableGrid({ items }: { items: GridItem[] }) {
  return (
    <div className="grid grid-cols-3 gap-4">
      {items.map((item) => (
        <motion.div
          key={item.id}
          layout
          transition={{
            layout: { duration: 0.3, ease: [0.22, 1, 0.36, 1] },
          }}
          className="rounded-lg border bg-card p-4"
        >
          {item.label}
        </motion.div>
      ))}
    </div>
  );
}

When the items array reorders, each motion.div smoothly animates to its new grid position. No manual coordinate math. Framer Motion uses the FLIP technique under the hood.

Shared element transitions use layoutId:

"use client";

import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";

const cards = [
  { id: "a", title: "Card A", body: "Card A body content..." },
  { id: "b", title: "Card B", body: "Card B body content..." },
];

export function CardGallery() {
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const selected = selectedId ? cards.find((c) => c.id === selectedId) : null;

  return (
    <>
      <div className="grid grid-cols-2 gap-4">
        {cards.map((card) => (
          <motion.div
            key={card.id}
            layoutId={`card-${card.id}`}
            onClick={() => setSelectedId(card.id)}
            className="cursor-pointer rounded-lg border bg-card p-4"
          >
            <motion.h3 layoutId={`title-${card.id}`}>{card.title}</motion.h3>
          </motion.div>
        ))}
      </div>

      <AnimatePresence>
        {selected && (
          <motion.div
            key="modal"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={() => setSelectedId(null)}
            className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
          >
            <motion.div
              layoutId={`card-${selected.id}`}
              className="rounded-lg bg-card p-8 max-w-md"
            >
              <motion.h2 layoutId={`title-${selected.id}`}>{selected.title}</motion.h2>
              <p>{selected.body}</p>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </>
  );
}

The card in the grid and the card in the modal share the same layoutId. When the modal opens, Framer Motion animates the card from its grid position to the modal position. When the modal closes, it animates back. The title inside the card has its own layoutId so it also transitions smoothly.

Add a layout animation section to CLAUDE.md:

## Layout animations

- layout prop: animates position/size changes on a single element
- layoutId: animates between two different elements that share an ID
- Both elements with the same layoutId MUST NOT be mounted simultaneously
  (use AnimatePresence for the source/destination toggle)
- For grid reorders: motion.div with layout prop on each grid item
- For shared element transitions: matching layoutId on both source and destination
- Transition timing: customise via transition={{ layout: { duration, ease } }}

For a deeper look at the React component patterns that pair with layout animations, Claude Code with React covers the keying and reconciliation rules that determine when these transitions fire.

Gestures and interaction states

Framer Motion's gesture props (whileHover, whileTap, whileFocus, whileInView, whileDrag) compose directly with variants. They are the cleanest way to add interaction polish.

"use client";

import { motion } from "framer-motion";

export function InteractiveButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
  return (
    <motion.button
      onClick={onClick}
      whileHover={{ scale: 1.04 }}
      whileTap={{ scale: 0.96 }}
      transition={{ duration: 0.15, ease: [0.22, 1, 0.36, 1] }}
      className="rounded-lg bg-black px-4 py-2 text-white"
    >
      {children}
    </motion.button>
  );
}

Scroll-triggered animations with whileInView:

"use client";

import { motion } from "framer-motion";

export function Section({ title, body }: { title: string; body: string }) {
  return (
    <motion.section
      initial={{ opacity: 0, y: 30 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-100px" }}
      transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
      className="py-16"
    >
      <h2 className="text-2xl font-bold">{title}</h2>
      <p>{body}</p>
    </motion.section>
  );
}

viewport={{ once: true }} prevents the animation from replaying when the user scrolls past the section a second time. margin: "-100px" triggers the animation 100px before the element enters the viewport, which avoids the awkward feeling of elements animating in just after they become visible.

Add a gestures section to CLAUDE.md:

## Gestures and interaction

- whileHover, whileTap, whileFocus: pair with motion components for polish
- whileInView with viewport={{ once: true, margin: "-100px" }} for scroll reveals
- Default scale values: hover 1.04, tap 0.96 (subtle, not bouncy)
- Drag interactions: drag, dragConstraints, dragElastic
- NEVER chain expensive animations on hover (subtree-wide motion lags)
- whileTap is preferable to onClick + state for press feedback

Reduced motion handling

Animations that ignore prefers-reduced-motion cause real harm to users with vestibular disorders. Framer Motion has first-class support that Claude does not invoke by default.

Global setup via MotionConfig (already in providers.tsx above):

<MotionConfig reducedMotion="user">
  {children}
</MotionConfig>

reducedMotion="user" reads the operating system preference. reducedMotion="always" forces reduced motion. reducedMotion="never" ignores the preference. "user" is the correct default.

For per-component branching, the useReducedMotion hook:

"use client";

import { motion, useReducedMotion } from "framer-motion";

export function ParallaxBanner({ children }: { children: React.ReactNode }) {
  const shouldReduce = useReducedMotion();

  if (shouldReduce) {
    return (
      <motion.div
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        transition={{ duration: 0.3 }}
        className="relative h-96 bg-gradient-to-br from-blue-500 to-purple-600"
      >
        {children}
      </motion.div>
    );
  }

  return (
    <motion.div
      initial={{ opacity: 0, y: 100 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
      className="relative h-96 bg-gradient-to-br from-blue-500 to-purple-600"
    >
      {children}
    </motion.div>
  );
}

The reduced-motion path fades only. The full-motion path translates. Both arrive at the same end state.

Add reduced motion to CLAUDE.md:

## Reduced motion (accessibility, MANDATORY)

- MotionConfig reducedMotion="user" wraps every entry point
- For complex motion (parallax, large translations, rotations):
  branch with useReducedMotion() and fall back to opacity-only
- NEVER respect "always" or "never" hardcoded, always honour user preference
- Decorative loops (pulsing icons): pause when reduced motion is requested

Common Claude Code mistakes with Framer Motion

Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.

1. Inline animate objects

Claude generates: <motion.div animate={{ opacity: 1, y: 0 }} initial={{ opacity: 0, y: 20 }}> on every component.

Correct pattern: named Variants object, reused across components.

2. Missing key on AnimatePresence child

Claude generates: <AnimatePresence>{open && <motion.div exit={{ opacity: 0 }}>...</motion.div>}</AnimatePresence> with no key.

Correct pattern: <AnimatePresence mode="wait"><motion.div key={open ? "open" : "closed"} exit={{ opacity: 0 }}>...</motion.div></AnimatePresence>.

3. Animating width/height

Claude generates: animate={{ width: expanded ? 300 : 100 }}.

Correct pattern: animate={{ scaleX: expanded ? 1 : 0.33 }} with transformOrigin: "left", or use layout prop.

4. No initial={false} on AnimatePresence

Claude generates: <AnimatePresence> wrapping a list where the initial mount should not animate.

Correct pattern: <AnimatePresence initial={false}> so first-paint elements appear immediately.

5. Missing reduced motion handling

Claude generates: whileInView={{ y: 0 }} initial={{ y: 100 }} with no fallback.

Correct pattern: useReducedMotion() branch or rely on MotionConfig reducedMotion="user" globally with opacity-only animations.

6. motion.div on non-animating elements

Claude generates: <motion.div className="container"> for a static wrapper because it sits inside a tree that uses motion.

Correct pattern: regular <div> for static, motion.div only when something animates.

Mistake Symptom Fix
Inline animate Unmaintainable, duplicate motion Named variants
Missing key Exit animation silently does nothing Unique key per state
Width/height Janky, layout thrashing Transform + layout prop
No initial={false} Awkward first-mount animation initial={false}
No reduced motion Accessibility failure MotionConfig user
Unnecessary motion.div Wasted reconciliation Plain div

Add a common mistakes section to CLAUDE.md with these six pairs. Claude benefits from explicit before/after comparisons because it can match the pattern it would otherwise generate to the corrected form.

Looking to ship maintainable animations faster? Get Claudify. Pre-built CLAUDE.md templates for Framer Motion and every major React tool, ready to drop into your project.

Permission hooks for animation tooling

A Framer Motion project accumulates scripts: visual regression tests, motion previews, design token sync, accessibility audits. Some are read-only. Some modify your design system. Permission hooks gate the destructive ones.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(npm run dev*)",
      "Bash(npm run storybook*)",
      "Bash(npx playwright test --grep visual*)",
      "Bash(npx axe-core*)"
    ],
    "deny": [
      "Bash(npm run sync-design-tokens*)",
      "Bash(node scripts/regenerate-variants.js*)"
    ]
  }
}

Running Storybook to preview animations is safe. Visual regression tests are safe. Regenerating the variant library or syncing design tokens needs explicit confirmation. The deny list forces Claude to surface those operations as prompts rather than running them mid-task.

Building animations that ship

The Framer Motion CLAUDE.md in this guide produces animation code where variants are the unit of composition, transforms are the only properties animated, AnimatePresence children always have unique keys, mode="wait" is set for single-child swaps, layout animations use layout and layoutId instead of manual coordinate math, and reduced-motion preferences are respected globally and per-component.

The underlying principle is the same as any library integration with Claude Code. Framer Motion without a CLAUDE.md produces code that looks correct and ships without TypeScript errors, but fails in ways that are hard to diagnose: exit animations that silently do nothing, performance regressions on grid reorders, accessibility failures on parallax sections, and unmaintainable JSX with motion objects copy-pasted across the codebase. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude can generate.

For the next layer up from component animations, Framer Motion pairs well with Claude Code with Storybook for visual regression testing of motion states, and Claudify includes a Framer Motion CLAUDE.md template with the variants library, AnimatePresence patterns, layout animation rules, reduced motion handling, and all six common-mistake pairs pre-configured.

Get Claudify. Ship production-ready Framer Motion code with Claude Code from the first session.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame