Skip to content

Motion Layout Troubleshooting

A UI principle for coding agents. Also covers layout animation bugs, layoutId, stretched text, layout projection, FLIP debugging, jiggling elements, and 3 more.

Show all 9 aliases

layout animation bugs, layoutId, stretched text, layout projection, FLIP debugging, jiggling elements, LayoutGroup, hot reload broke my animation, reduced motion setup

Layout animations interpolate between two measured layouts using transforms, which is why they are fast, and also why they fail in characteristic ways: anything a scale() can distort will distort, and anything identity-based breaks when identities are wrong. When to reach for layout animation at all, and how shared-element transitions work, is covered in animation-and-motion. This doc is the debugging reference: find the symptom, apply the fix.

The Technique Is Layout Projection, Not FLIP

The two are related enough that people search for "FLIP", so keep the word in your head as a synonym, but they are not the same algorithm, and the difference explains several bugs below.

  • FLIP measures the element before and after the change, applies an inverting transform so it still looks like it did before, then animates that transform down to zero. The delta is computed once, at the start
  • Layout projection turns that delta into a target bounding box and recomputes, every frame, the transform needed to get the element from where the browser has actually put it to where it should appear. Because the target is a box rather than a fixed delta, the animation composes with transforms on ancestors, survives being interrupted and retargeted mid-flight, and can correct scale distortion on descendants
  • The practical consequence: a projected element's on-screen position is the product of its own transform and every projecting ancestor's. When a parent's layout changes at the same time as a child's, they negotiate, which is why nested motion elements on different transitions visibly fight (see below)

Distorted Content

Text stretches or warps while a container resizes

Cause: the container is being scaled between its two sizes, and a scale transform stretches everything inside it, text included, like resizing a screenshot.

Fix: make the text a motion element of its own with layout="position". It then counter-transforms against the parent every frame, moving to its new position without inheriting the size change. layout accepts true (animate position and size), "position", or "size".

<motion.div layout className="card">
  <motion.p layout="position">Order complete</motion.p>
</motion.div>

Nesting a motion element is the general antidote to scale distortion; it works for icons, images, and avatars as well as text.

Text jumps to its new position at the very start

Cause: the library animates the text's box, not its characters. If the box spans the full container width (a block-level paragraph with text-align: center, for example), the box barely moves between layouts, but the characters inside it reflow instantly.

Fix: shrinkwrap the box around its content so the box and the characters are the same thing. Flexbox on the parent is the cleanest way:

.card {
  display: flex;
  justify-content: center;
  align-items: flex-start;
}

Parent and child jiggle or vibrate against each other

Cause: two nested motion elements are animating the same layout change on different transition curves. Transition settings do not cascade from parent to child, so a parent with a custom spring and a child on defaults arrive at different times and fight visibly on the way.

Fix: give every motion element in the nested group the same transition object.

const spring = { type: "spring", stiffness: 260, damping: 34 };

<motion.div layout transition={spring}>
  <motion.p layout="position" transition={spring}>Filters</motion.p>
</motion.div>

Spring parameter tuning itself is covered in motion-hooks.

Corners twitch or warp while the element resizes

Cause: border-radius is defined in pixels, so scaling the box scales the apparent radius with it. A pill stretched to twice its width shows visibly squashed corners mid-flight.

Fix: declare the radius through the library instead of only in CSS, so it can apply the inverse correction every frame. Motion enables the correction when the property is set through style, initial, animate or another animation prop. Use initial:

<motion.div layout initial={{ borderRadius: 16 }} />

Prefer initial over style here. A radius in style is a live animation target, so the moment it differs from what the element already renders, the corners animate on mount as well as during layout changes. initial states the resting radius once, enables the correction, and adds no mount animation. The same distortion affects box-shadow, and the same fix applies, but only a single shadow is corrected: layered shadow stacks stay distorted, so keep shadows simple on elements that resize.

Broken Shared-Element Transitions

The element teleports instead of animating

Three causes, in order of likelihood:

  1. No exit handling. When the element unmounts, React removes it instantly and there is nothing left to animate. Wrap the conditional in AnimatePresence, which holds the element in the DOM until its exit (or the return leg of a shared layout animation) finishes. AnimatePresence itself must stay mounted; put the condition on the child, and give each child a stable, unique key (never an array index).
  2. The two layoutId values do not actually match. A typo, or an id built from state that differs between the two renders, means the library sees two unrelated elements. Log both values.
  3. The elements live in disconnected trees. Wrap the related subtrees in one LayoutGroup so their layout measurements are coordinated; this also fixes items that move one at a time when several should travel together.

Cause: usually a key / layoutId mismatch on the same element. When the two diverge (for example key={index} with layoutId={id}), React's identity and the animation identity disagree, and the element can vanish mid-transition. The other cause is two elements mounted at the same time with the same layoutId: the library treats them as one and crossfades, which reads as flicker if unintended.

Fix: use the same value for both props, and keep each layoutId mounted at most twice, and only deliberately, during a handoff:

<motion.div key={widget.id} layoutId={widget.id} />

One item in a list never animates

Cause: its layoutId is falsy. Ids derived from array positions produce 0 for the first item, and a falsy id is treated as no id at all.

Fix: build ids that are always truthy strings, and globally unique. useId plus the item identifier is a reliable recipe:

import { useId } from "react";

function ItemList({ items }) {
  const id = useId();

  return items.map((item) => (
    <motion.li key={`${id}-${item.num}`} layoutId={`${id}-${item.num}`} />
  ));
}

Two instances of a component hijack each other

Cause: layoutId is global to the page. Two tab rows that both render layoutId="active-tab" share one identity, so selecting a tab in one row flies the indicator across the screen from the other.

Fix: namespace each instance with a LayoutGroup id, or bake instance identity into the layoutId itself:

<LayoutGroup id={rowId}>
  <TabRow items={items} />
</LayoutGroup>

The shared transition works but feels wrong

Cause: often not a bug but a modeling error. A shared layout animation asserts that two DOM nodes are the same entity moving to a new place. That assertion has two valid shapes. In a fungible model the items are interchangeable and derived from counts (a progress visualizer moving dots between buckets); any dot may play the moving role. In a non-fungible model each item has its own identity and state, and the layoutId must come from that item's own stable id, because users can act on a specific item.

Fix: if users can select, edit, or reorder individual items, give each item real identity in the data model and derive layoutId from it. If two elements merely look similar but represent different things, do not connect them with a layoutId at all; a morph between unrelated entities confuses more than it delights.

The element slides underneath its siblings

Cause: siblings at the same stacking level layer by DOM order, so a shared element travelling from a later item passes over earlier ones.

Fix: raise z-index on the active item (or lower it on the element that should sit behind) so layering follows the interaction instead of source order.

A travelling backdrop covers the labels it slides past

Cause: the same stacking rule, in the direction people usually get wrong. A tab indicator, segmented-control pill or menu highlight is a filled surface that has to pass behind its neighbours. Reach for the previous fix by reflex, raise z-index on the active item, and the pill now paints over every label it crosses, so each one disappears and reappears as it travels.

Fix: invert it. Lower the active item and raise its siblings, so the backdrop travels underneath the text.

<button style={{ position: "relative", zIndex: isActive ? 0 : 1 }}>
  {isActive && (
    <motion.span layoutId="tab-pill" style={{ position: "absolute", inset: 0 }} />
  )}
  <span style={{ position: "relative", zIndex: 1 }}>{tab.name}</span>
</button>

Two levels are needed, because the pill has to pass behind its own label as well as behind the neighbours it crosses. z-index only applies to positioned elements, so both the item and the label need position set for either value to take effect. Decide per case which reading is right: a shared element that becomes the new surface travels on top, a backdrop that decorates whatever it lands on travels underneath.

Nothing animates at all

Check display: inline elements cannot be transformed, so a bare <span> or <a> needs display: block or inline-block. And if the opposite happens, layout animations firing when unrelated parts of the page reflow, scope the measurement boundary with layoutRoot on a fixed-position container.

Hammering the control makes elements skid, stutter or land in the wrong place

This one has no clean fix. Say so in review rather than burning a day on it.

Cause: each re-trigger is a fresh layout change. The engine remeasures and hands every projecting element a new target box while the previous flight is still in progress, so the layout is pulled out from under elements that have not arrived yet. Hammer a tab row and the indicator can appear to skip a stop, overshoot the row, or briefly land between two items.

Mitigations, both of which are trade-offs:

  • Stiffen the spring so each flight finishes inside the fastest interval a user can produce. Raise stiffness, or drop the duration, until re-triggers stop overlapping. You lose the languid feel that made you pick a spring
  • Disable the control for the flight duration. Set disabled on the buttons, clear it in onLayoutAnimationComplete. This is honest and it works, but a disabled control ignores a genuine fast click, so keep the window as short as the animation actually is, not a padded round number

Do not try to queue the triggers. A queue turns a dropped frame into a backlog of animations the user has to sit through, which is worse than the skid.

The code is right but the animation is broken until you reload

Cause: hot module reloading swaps component code while the projection engine is holding measurements taken from the previous tree. The measurements no longer describe what is on screen, so elements fly to stale positions, refuse to animate, or animate the wrong property.

Fix: do a full page reload before you conclude the code is wrong. Make this the first step when debugging any layout animation, because the failure looks exactly like a genuine bug and has cost people entire afternoons. If a full reload fixes it, there was nothing to fix.

Reduced Motion

Honoring the OS setting

Layout and transform animations are exactly the kind vestibular-sensitive users opt out of. One wrapper at the app root makes the whole tree respect the OS preference:

<MotionConfig reducedMotion="user">
  <App />
</MotionConfig>

reducedMotion takes "user" (follow the device setting), "always" (force reduced motion), or "never". When reduced motion is active, transform and layout animations are disabled while non-transform values such as opacity and color still animate, so state changes stay visible. "always" and "never" are what an in-app motion toggle sets, overriding the OS in either direction. For per-component decisions, the useReducedMotion hook returns the live preference as a boolean.

CSS-driven motion is not covered by MotionConfig; gate it separately behind @media (prefers-reduced-motion: no-preference).

Testing it

Emulate the preference in Chrome DevTools instead of toggling the OS setting: Command palette (Cmd/Ctrl+Shift+P), then "Emulate CSS prefers-reduced-motion". Re-render the app after toggling and walk the key flows. Every state change must remain understandable when elements jump instead of glide.

Next.js: keep the root layout a server component

Symptom: rendering MotionConfig (or any motion component) straight from a server component throws at render time. Next.js rewrites the underlying React error into the form useState only works in Client Components. Add the "use client" directive at the top of the file to use it. The named hook varies with whichever client-only hook the component reached for first, so match on the sentence, not the hook.

The obvious response, putting "use client" at the top of app/layout.tsx, works and costs you the Metadata API, because the layout is now a client component. Two ways out that do not:

  • Wrap it. Importing a small client component from a server component does not move the boundary, so the layout stays a server component
  • Import the pre-marked client entry. motion/react-client exists for exactly this: it exports the same components already marked as client components, so a server component can render <motion.div> without a directive of its own. It does not help with MotionConfig, which still needs a client boundary to hold its context

Wrap MotionConfig once and use the wrapper in the layout:

// components/motion-preferences.jsx
"use client";
import { MotionConfig } from "motion/react";

export function MotionPreferences({ children }) {
  return <MotionConfig reducedMotion="user">{children}</MotionConfig>;
}

The same SSR logic applies to any rendering you branch on the preference yourself: the server cannot know the media query, so markup that differs between server and client render will mismatch on hydration. Render identical markup on both, and let the preference take effect through CSS media queries or through client-side animation config after mount.

The correctness rule: reduced motion never hides content

A reduced-motion fallback replaces movement, not visibility. The classic failure: an element starts at opacity: 0 waiting for an entrance animation that the reduced-motion path never runs, leaving invisible content for exactly the users who asked for less motion. Audit every initial hidden state and every scroll-triggered reveal under emulated reduced motion. Swap translation and scale for a plain opacity fade, or for no animation at all, but the resting state must always be fully visible.

Checklist

  • Text inside resizing containers sits in its own motion element with layout="position"
  • Nested motion elements share one transition object
  • Elements that resize declare borderRadius (and any shadow) via initial
  • Conditional motion elements are wrapped in AnimatePresence with stable keys
  • key and layoutId use the same value; ids are truthy, unique strings
  • Repeated components are namespaced with LayoutGroup ids
  • Shared transitions connect only elements that represent the same entity
  • Travelling backdrops sit below their siblings, not above
  • Rapidly re-triggerable controls have a stiff enough spring, or are disabled for the flight
  • Any "this animation is broken" report was reproduced after a full page reload, not over hot reload
  • MotionConfig reducedMotion="user" wraps the app via a client wrapper in Next.js
  • Under emulated reduced motion, every screen is fully visible and understandable

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "motion-layout-troubleshooting" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems