DEV Community

Bishop Z
Bishop Z

Posted on

Motion (motion/react) in production: layout animations and accessible motion patterns

If you've used framer-motion in the last few years, you might not realize the library has moved on without you. Since late 2024, the package is just Motion, the import path is motion/react, and the API surface is cleaner than it's ever been. Most tutorials haven't caught up. They still show framer-motion imports, bouncing boxes, and no accessibility story.

This article is different. We'll build one component — an expandable card list — and use it to cover three things that matter in production: the motion/react import path and what the rebrand means for your codebase, layout animations in a real component tree, and accessible motion via useReducedMotion with a pattern that actually works.

Live demo → Open the StackBlitz project — fork it, break it, rebuild it. Every code block below comes from that demo.

Prerequisites. Comfortable with React hooks and TypeScript. Motion v12+ on React 18.


The rebrand: framer-motion → motion/react

In late 2024, Matt Perry separated Motion from Framer the company. The npm package became motion, and the React-specific import path moved to motion/react. The API itself didn't break — if you're on v11, the v12 upgrade is a find-and-replace.

npm uninstall framer-motion
npm install motion
Enter fullscreen mode Exit fullscreen mode

Then swap every import:

// before
import { motion, AnimatePresence } from 'framer-motion';

// after
import { motion, AnimatePresence } from 'motion/react';
Enter fullscreen mode Exit fullscreen mode

That's it for most codebases. If you're in a monorepo where some packages still reference framer-motion, both can coexist during migration — they're separate npm packages with no shared global state. But don't let that coexistence linger. Pick a sprint, do the swap, and move on.

Every import in this article uses the modern motion/react path.


The example we're building

We're building an expandable card list — three to five cards, each with a header that's always visible and a detail panel that mounts and unmounts on click. It's the kind of component you'd find in a Polaris or Spectrum design system page, not a demo with colored boxes.

Fork it, break it, rebuild it. Every code block in this article is pulled directly from that demo.


Layout animations in a real component tree

Most tutorials explain layout with a single element changing size. That's fine for understanding the concept, but it falls apart the moment you have a parent-child relationship where both the list and the individual items need to animate. Here's how to think about it in a real component tree.

Auto-layout with layout

The layout prop tells Motion to watch an element's position and size. When either changes — because a sibling mounted, a parent resized, or the element's own content changed — Motion automatically animates the transition instead of letting the browser snap to the new layout.

In our card list, the ul and each li use layout="position". That slides siblings when a card grows or shrinks, but does not scale the card itself — full layout applies transform-based size animation and will squash fixed headers and titles. Height changes stay inside the card: the detail panel uses a CSS grid accordion (0fr1fr), not layout on the list item.

import { motion, AnimatePresence } from 'motion/react';
import { useState } from 'react';
import { useMotionPreset } from '../hooks/useMotionPreset';
import { Card } from './Card';
import type { CardData } from '../data/cards';

export const ExpandableCardList = ({ cards }: { cards: CardData[] }) => {
  const [expandedId, setExpandedId] = useState<string | null>(null);
  const transition = useMotionPreset();

  return (
    <motion.ul layout="position" transition={transition} className="card-list">
      {cards.map((card) => (
        <motion.li key={card.id} layout="position" transition={transition}>
          <Card
            card={card}
            expanded={expandedId === card.id}
            onToggle={() =>
              setExpandedId(expandedId === card.id ? null : card.id)
            }
            transition={transition}
          />
        </motion.li>
      ))}
    </motion.ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

Sharing identity with layoutId and AnimatePresence

layoutId is the judgment call most tutorials skip. When two elements share a layoutId, Motion treats them as the same visual object — even if they're in completely different parts of the tree. This is how shared-element transitions work (think: a thumbnail that expands into a detail view).

In our card component, we don't need layoutId for the shared-element pattern. We need AnimatePresence for something simpler: the detail panel mounts and unmounts, and we want an exit animation. Without AnimatePresence, React removes the element from the DOM before Motion can animate it out. For the height transition, avoid animating to height: 'auto' — springs round to pixel heights, clip the last line during the tween, then snap when the style hands off to auto. Use a CSS grid wrapper (grid-template-rows: 0fr1fr) with overflow: hidden and padding on an inner child instead.

import { motion, AnimatePresence } from 'motion/react';
import type { CardData } from '../data/cards';
import type { Transition } from 'motion/react';

interface CardProps {
  card: CardData;
  expanded: boolean;
  onToggle: () => void;
  transition: Transition;
}

const detailTransition = (transition: Transition) => {
  if ('duration' in transition && transition.duration === 0) {
    return transition;
  }

  return {
    opacity: transition,
    gridTemplateRows: { type: 'tween' as const, duration: 0.35, ease: [0.4, 0, 0.2, 1] as const },
    height: { type: 'tween' as const, duration: 0.35, ease: [0.4, 0, 0.2, 1] as const },
  };
};

export const Card = ({ card, expanded, onToggle, transition }: CardProps) => (
  <div className="card">
    <button
      onClick={onToggle}
      aria-expanded={expanded}
      className="card-header"
    >
      <h3>{card.title}</h3>
      <p>{card.summary}</p>
    </button>
    <AnimatePresence initial={false}>
      {expanded && (
        <motion.div
          key="detail"
          initial={{ gridTemplateRows: '0fr', opacity: 0 }}
          animate={{ gridTemplateRows: '1fr', opacity: 1 }}
          exit={{ gridTemplateRows: '0fr', opacity: 0, height: 0 }}
          transition={detailTransition(transition)}
          className="card-detail-wrapper"
          style={{ display: 'grid' }}
        >
          <div className="card-detail">
            <p>{card.body}</p>
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  </div>
);
Enter fullscreen mode Exit fullscreen mode

The distinction matters: layout is for elements that stay in the DOM but move. AnimatePresence is for elements that enter and leave the DOM. layoutId is for when you want Motion to treat two different DOM nodes as one continuous visual object. In a real component tree, you'll often use all three together. Our card list uses layout="position" on list items plus AnimatePresence on the detail panel. Split responsibilities: siblings reflow with position layout; height animates only inside the grid wrapper. Never size-animate a container that holds text you need to stay crisp.


Accessible motion: useReducedMotion in production

The prefers-reduced-motion media query is not optional. About 30% of iOS users have it enabled (not all for vestibular disorders — some just don't like animation). If your motion strategy is "turn everything off," you're doing it wrong.

The common antipattern is gating the entire component — wrapping your <motion.div> in a conditional that falls back to a plain <div>. This breaks layout animations entirely. The list snaps, the siblings teleport, and your carefully crafted expand/collapse behavior degrades to a content pop.

The right pattern: gate the transition, not the component. The <motion.div> stays in the tree. It still manages layout. It just does it instantly instead of over 300ms.

import { useReducedMotion } from 'motion/react';

export const useMotionPreset = () => {
  const shouldReduce = useReducedMotion();
  return shouldReduce
    ? { duration: 0 }
    : { type: 'spring' as const, stiffness: 350, damping: 30 };
};
Enter fullscreen mode Exit fullscreen mode

This single hook feeds every transition prop in the card list. When the user has prefers-reduced-motion: reduce set, all animations resolve instantly. The layout still works — elements still end up in the right place — they just get there without the spring. Expand a card with reduced motion on, and the detail panel appears immediately. Collapse it, and it disappears immediately. No jank, no teleportation, no broken layout.

Production teams should extract this into a shared utility. If your app has more than one animated component, every transition should flow through the same useMotionPreset hook. That gives you a single control surface for motion behavior across the entire application.


Motion vs. the Web Animations API

If you don't need React, don't need layout animations, and your animations are simple keyframe sequences — use the browser-native Web Animations API. It's zero dependencies, runs on the compositor thread, and performs well. A CSS @keyframes fade-in doesn't need a JavaScript library.

Motion wins when you need a React-native API that understands the component lifecycle. Layout animations that track DOM position changes, AnimatePresence for exit animations on unmounting components, spring physics that feel right without hand-tuning cubic beziers, and a declarative API that composes with React state — that's what a library buys you. The expandable card list we just built would be significantly more code and significantly less reliable with raw WAAPI.


What to try next

If you've worked through the demo, here are three specific things to try that push the pattern further.

  1. Motion-aware design tokens. Replace the hardcoded spring values in useMotionPreset with design tokens from your system. A motion.standard token that returns the right transition for your brand's feel, gated by reduced-motion, is a powerful primitive.

  2. Scroll-linked animations with useScroll. Add a sticky card header that fades in as you scroll past the card list. useScroll returns a MotionValue you can pipe into opacity and transforms without triggering re-renders.

  3. Extract the pattern into a reusable hook. The expand/collapse + layout animation + accessible transition combo is generic enough to wrap in a useExpandable hook. Your future self will thank you when the second expandable component shows up.


This article originally appeared on bishopz.com. I write about production React, design systems, and the things tutorials skip. If you're hiring for design engineering or frontend architecture roles — let's talk.

Top comments (0)