# Motion Patterns — Motion for React

> **Scope:** frontend-nextjs-ui
> **Layer:** 3
> **Keywords:** motion, framer-motion, animation, transition, variants, gesture, scroll
> **Load When:** nextjs-expert active

**Verified against:** Motion for React 12 (`motion`, imports from `motion/react`). Last-verified: 2026-05-20.

---

Reference guide for implementing animations and transitions in Next.js projects using **Motion for React**, the library formerly published as `framer-motion`. The package is now `motion` and components import from `motion/react`; the `framer-motion` name only survives inside the Framer design tool. Covers the canonical variants library, design token bridge, scroll and gesture patterns, loading states, and mandatory WCAG accessibility compliance.

**Scope, and where the other motion stack starts.** This standard governs component-level motion: enter and exit, gestures, mount and unmount transitions, short scroll reveals in an app or product UI. When the scroll position itself drives the scene (frame scrubs, pinned sequences, parallax, a cinematic landing page), that is GSAP plus ScrollTrigger plus Lenis: see `frontend/scroll-driven/smooth-scroll.md` and `frontend/gsap/gsap-core.md`. The two stacks may share a page but never the same element, because both write inline styles and will fight for control. On a page that runs Lenis, use the `useReveal` recipe in `frontend/scroll-driven/scroll-components.md` rather than `whileInView`, so a single library owns the answer to when an element is visible.

---

## Section 1: Core Variants Library

Place this file at `motion/variants.ts` in your Next.js project root.

```typescript
import { Variants } from 'motion/react';
import { durations, easings } from '@/lib/design-tokens'; // maps to design-system.md motion tokens

// Fade
export const fadeIn: Variants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1, transition: { duration: durations.base, ease: easings.standard } },
};

// Slide variations
export const slideInUp: Variants = {
  hidden: { opacity: 0, y: 24 },
  visible: { opacity: 1, y: 0, transition: { duration: durations.base, ease: easings.decelerate } },
};
export const slideInDown: Variants = {
  hidden: { opacity: 0, y: -24 },
  visible: { opacity: 1, y: 0, transition: { duration: durations.base, ease: easings.decelerate } },
};
export const slideInLeft: Variants = {
  hidden: { opacity: 0, x: -24 },
  visible: { opacity: 1, x: 0, transition: { duration: durations.base, ease: easings.decelerate } },
};
export const slideInRight: Variants = {
  hidden: { opacity: 0, x: 24 },
  visible: { opacity: 1, x: 0, transition: { duration: durations.base, ease: easings.decelerate } },
};

// Scale
export const scaleIn: Variants = {
  hidden: { opacity: 0, scale: 0.92 },
  visible: { opacity: 1, scale: 1, transition: { duration: durations.fast, ease: easings.bounce } },
};

// Stagger container + child
export const staggerContainer: Variants = {
  hidden: {},
  visible: {
    transition: {
      staggerChildren: 0.08,
      delayChildren: 0.1,
    },
  },
};
export const staggerItem: Variants = {
  hidden: { opacity: 0, y: 16 },
  visible: { opacity: 1, y: 0, transition: { duration: durations.base, ease: easings.decelerate } },
};

// Page transition (for AnimatePresence in layout.tsx)
export const pageTransition: Variants = {
  hidden: { opacity: 0, y: 8 },
  visible: { opacity: 1, y: 0, transition: { duration: durations.base, ease: easings.decelerate } },
  exit: { opacity: 0, y: -8, transition: { duration: durations.fast, ease: easings.accelerate } },
};
```

---

## Section 2: Design Token Bridge

This file bridges the CSS variables defined in `.morph/context/design-system.md` to JavaScript values consumed by Framer Motion. Place at `lib/design-tokens.ts`.

```typescript
// lib/design-tokens.ts
// Maps CSS variables from .morph/context/design-system.md to JS values for Framer Motion
export const durations = {
  instant: 0.05,
  fast: 0.15,
  base: 0.3,
  slow: 0.5,
  slower: 0.8,
} as const;

export const easings = {
  standard: [0.4, 0, 0.2, 1] as [number, number, number, number],
  decelerate: [0, 0, 0.2, 1] as [number, number, number, number],
  accelerate: [0.4, 0, 1, 1] as [number, number, number, number],
  bounce: [0.34, 1.56, 0.64, 1] as [number, number, number, number],
  spring: [0.68, -0.55, 0.265, 1.55] as [number, number, number, number],
};
```

> **Rule**: Never hardcode duration numbers or easing arrays in component files. Always import from `@/lib/design-tokens`.

---

## Section 3: Scroll Animations

Two approaches for triggering animations when elements enter the viewport:

```tsx
'use client';
import { motion, useInView } from 'motion/react';
import { useRef } from 'react';
import { fadeIn, slideInUp } from '@/motion/variants';

// Option A: whileInView (simpler, one-shot)
<motion.div
  variants={slideInUp}
  initial="hidden"
  whileInView="visible"
  viewport={{ once: true, margin: '-50px' }}
>

// Option B: useInView hook (more control)
function AnimatedSection({ children }: { children: React.ReactNode }) {
  const ref = useRef(null);
  const isInView = useInView(ref, { once: true, margin: '-100px' });
  return (
    <motion.div ref={ref} variants={fadeIn} initial="hidden" animate={isInView ? 'visible' : 'hidden'}>
      {children}
    </motion.div>
  );
}
```

- Use `once: true` for content sections — animations should not replay on scroll up.
- Adjust `margin` to fire slightly before the element fully enters the viewport.
- Prefer `whileInView` for simple cases; use `useInView` when you need imperative control or conditional logic.

---

## Section 4: Gesture Interactions

```tsx
import { durations, easings } from '@/lib/design-tokens';

// Hover + tap on cards
<motion.div
  whileHover={{ y: -4, boxShadow: 'var(--shadow-lift)' }}
  whileTap={{ scale: 0.98 }}
  transition={{ duration: durations.fast, ease: easings.standard }}
>

// Drag interaction
<motion.div
  drag
  dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }}
  dragElastic={0.1}
  whileDrag={{ scale: 1.05, cursor: 'grabbing' }}
>
```

- `whileHover` and `whileTap` are inline — they do not need variant definitions for simple state changes.
- Use `var(--shadow-lift)` from the design system rather than hardcoded box-shadow values.
- `dragElastic={0.1}` gives a subtle rubber-band feel without being distracting.

---

## Section 5: AnimatePresence (Page Transitions)

Integrate `AnimatePresence` at the layout level to animate between routes:

```tsx
// app/layout.tsx
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { usePathname } from 'next/navigation';
import { pageTransition } from '@/motion/variants';

export default function Layout({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();

  return (
    <html>
      <body>
        <AnimatePresence mode="wait">
          <motion.main
            key={pathname}
            variants={pageTransition}
            initial="hidden"
            animate="visible"
            exit="exit"
          >
            {children}
          </motion.main>
        </AnimatePresence>
      </body>
    </html>
  );
}
```

- `mode="wait"` ensures the exit animation completes before the entering page renders.
- The `key` prop must change on navigation — use `usePathname()` from `next/navigation`.
- Keep page transitions subtle (`y: 8`, fast duration) — aggressive transitions hurt perceived performance.

---

## Section 6: Loading Patterns

Prefer CSS `animate-pulse` for skeleton loaders over Framer Motion — CSS animations run off the main thread and have lower performance overhead.

```tsx
// components/skeleton.tsx
import { cn } from '@/lib/utils';

function Skeleton({ className }: { className?: string }) {
  return (
    <div
      className={cn(
        'animate-pulse rounded-[var(--radius-md)] bg-[var(--color-neutral-200)]',
        className
      )}
    />
  );
}

// Skeleton card example
function SkeletonCard() {
  return (
    <div className="p-[var(--space-6)] space-y-[var(--space-4)]">
      <Skeleton className="h-4 w-3/4" />
      <Skeleton className="h-4 w-1/2" />
      <Skeleton className="h-20 w-full" />
    </div>
  );
}
```

- Use design token CSS variables (`--radius-md`, `--color-neutral-200`, `--space-*`) — never hardcode dimensions or colors.
- Skeleton shapes should mirror the actual content layout to minimize layout shift on load.
- For spinner/progress indicators that need JS control, use a simple Framer Motion `animate={{ rotate: 360 }}` with `repeat: Infinity`.

---

## Section 7: WCAG Accessibility (MANDATORY)

> **This section is non-negotiable.** All animated components must respect `prefers-reduced-motion`.

### Hook pattern — wrap all animated components

```tsx
'use client';
import { motion, useReducedMotion } from 'motion/react';
import { slideInUp } from '@/motion/variants';

function AnimatedCard({ children }: { children: React.ReactNode }) {
  const shouldReduceMotion = useReducedMotion();

  const variants = shouldReduceMotion
    ? { hidden: { opacity: 0 }, visible: { opacity: 1 } }  // instant, no movement
    : slideInUp;

  return (
    <motion.div variants={variants} initial="hidden" animate="visible">
      {children}
    </motion.div>
  );
}
```

When `shouldReduceMotion` is `true`:
- Remove all `x`, `y`, `scale` transforms.
- Keep opacity transitions (they do not cause vestibular issues).
- Do not set `transition: { duration: 0 }` — a very short fade is acceptable and less jarring than an instant pop-in.

### CSS fallback (add to global stylesheet)

```css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}
```

This CSS fallback catches animations outside Framer Motion (Tailwind `animate-*`, third-party components, CSS keyframes).

---

## Section 8: Anti-patterns

| Anti-pattern | Correct approach |
|---|---|
| `duration: 0.8` for UI interactions | `duration: durations.fast` (0.15) for micro-interactions |
| Hardcoded easing `[0.4, 0, 0.2, 1]` | `ease: easings.standard` (from design-tokens) |
| No `useReducedMotion()` check | Always check or use CSS media query |
| Animating everything on the page | Reserve motion for 3-5 meaningful moments per page |
| Forgetting `animation-fill-mode: backwards` in stagger | Will cause a flash before the animation starts |
| `AnimatePresence` without `key` changes | Exit animations will never fire |
| Using `motion` for skeleton loaders | Use CSS `animate-pulse` — lower performance cost |
| `npm install framer-motion` / `import ... from 'framer-motion'` | `npm install motion` / `import ... from 'motion/react'` — `framer-motion` is the legacy name |
| Need ScrollTrigger, SVG morph, or complex timelines | Use GSAP — see `frontend/gsap/gsap-core.md` |

---

## Section 9: Installation

```bash
npm install motion
```

> The package is `motion` and components import from `motion/react`. The old
> `framer-motion` package still resolves as a deprecated alias, but new code
> should depend on `motion` directly. The `framer-motion` import name is only
> kept for projects running inside the Framer design tool.

Verify the installed version supports the features used here (requires motion >= 11):

```bash
npm list motion
```

---

*MORPH-SPEC by Polymorphism Tech*
