A filtered list can fade out its removed items while the remaining rows jump into place. A tab indicator can teleport between buttons. A carousel can slide in the wrong direction after a rapid click. These are different problems, even though all three look like animation bugs.
The useful distinction is what changes in the React tree. Does an existing element move? Does one element replace another? Does the direction of the transition depend on an action that just happened? Motion, the React animation library formerly known as Framer Motion, has separate tools for these cases. Choosing the right one matters more than adding more spring settings.
This guide uses the current Motion for React API and concentrates on state transitions you can reason about, debug, and test. The examples are illustrative; they have been reviewed against the documented API but have not been executed in a browser for this article.
First identify the kind of change
| What changed? | Start with | Why |
|---|---|---|
| An existing item changes size or position | layout | Motion measures the layout change and animates the visual transition. |
| A visual indicator moves between different elements | layoutId | The matching ID connects two render locations as one visual transition. |
| An element is removed from the React tree | AnimatePresence and exit | React would otherwise remove the DOM element before its exit animation. |
| Exit direction depends on a button or gesture | Dynamic variants and custom | The exiting element cannot receive ordinary updated props after removal. |
| Several children should enter in sequence | Parent and child variants | A parent transition can coordinate child timing. |
These tools can work together, but they do not replace each other. layout does not keep an item mounted for an exit. AnimatePresence does not calculate the reflow of surviving siblings. A matching layoutId does not provide a dialog's keyboard behavior.
Use the current package and a client boundary
The official Motion installation guide specifies React 18.2 or newer and the motion package:
npm install motion
Import its React APIs from motion/react. In a Next.js App Router application, put 'use client' at the top of an interactive component that uses React state and these Motion hooks.
You may still encounter framer-motion in existing projects, including older tutorials. It is the historical package name. The examples below intentionally use one documented import path, rather than mixing imports from two packages. No change to the portfolio application's own dependencies is required just to display this Markdown article.
Example 1: Animate a filter without jumping rows
Suppose a small issue list has an All and an Open filter. Two visual changes happen when you switch filters:
- The underline moves from one button to another. The underline is conditionally rendered in a different place, so it needs a shared
layoutId. - Closed rows leave the list and the remaining rows move upward. Exiting rows need
AnimatePresence, while surviving rows needlayout.
Here is a self-contained client component. The data is local, so it demonstrates the transition rather than a fetching or persistence workflow.
'use client'
import { useId, useState } from 'react'
import {
AnimatePresence,
LayoutGroup,
MotionConfig,
motion,
} from 'motion/react'
const issues = [
{ id: 'a', title: 'Review keyboard focus', open: true },
{ id: 'b', title: 'Update release notes', open: false },
{ id: 'c', title: 'Check empty states', open: true },
]
type Filter = 'all' | 'open'
export function IssueFilter() {
const [filter, setFilter] = useState<Filter>('all')
const groupId = useId()
const visible = issues.filter((issue) => filter === 'all' || issue.open)
return (
<MotionConfig reducedMotion="user">
<section aria-label="Issue list">
<LayoutGroup id={groupId}>
<div role="group" aria-label="Filter issues">
{(['all', 'open'] as const).map((option) => (
<button
key={option}
type="button"
aria-pressed={filter === option}
onClick={() => setFilter(option)}
style={{ position: 'relative', padding: '12px 16px' }}
>
{option === 'all' ? 'All' : 'Open'}
{filter === option && (
<motion.span
aria-hidden="true"
layoutId="selected-filter"
style={{
position: 'absolute',
left: 8,
right: 8,
bottom: 0,
height: 2,
backgroundColor: 'currentColor',
}}
/>
)}
</button>
))}
</div>
<motion.ul
layout
style={{ position: 'relative', listStyle: 'none', padding: 0 }}
>
<AnimatePresence initial={false} mode="popLayout">
{visible.map((issue) => (
<motion.li
key={issue.id}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
opacity: { duration: 0.15 },
layout: { duration: 0.25 },
}}
style={{ padding: 12 }}
>
{issue.title}
</motion.li>
))}
</AnimatePresence>
</motion.ul>
</LayoutGroup>
</section>
</MotionConfig>
)
}
Notice the three different identities. The React key on a row identifies the underlying issue and must stay stable as the list changes. The layoutId identifies a visual indicator that moves between different buttons. And LayoutGroup id={groupId} namespaces that shared ID so another instance of the component does not accidentally share its underline. This behavior is documented in LayoutGroup.
mode="popLayout" removes an exiting item from normal layout flow while its exit animation runs. The other rows can reflow immediately. This is suitable for the list above because each immediate child of AnimatePresence is a motion.li. If you replace those children with custom components, read the popLayout forwarding-ref requirement before copying the pattern. The list has position: relative to give popped elements a predictable positioning context.
The filter buttons are real buttons with aria-pressed. The moving underline is only decoration. Animation should not be the only indication of which filter is active.
What to inspect if the list still jumps
Check the cause before changing spring values:
- Rows vanish without fading: confirm
AnimatePresencestays mounted while the conditional children change, and give each child a stable data key. - Rows fade but surviving items jump: add
layoutto the elements whose positions actually change. Group separately rendered items if they affect one another's measurements. - The underline connects to another component: namespace the shared
layoutIdwith a uniqueLayoutGroup id. - An image or text stretches during a size change: layout animations use transforms and may scale descendants. Try
layout="position"when size interpolation is undesirable; see the layout troubleshooting guide. - A list sits inside an independently scrolling container: the container may need
layoutScrollso measurements account for its scroll offset.
Each symptom points to a different part of the animation system. Tuning the duration cannot fix a missing key or a wrong measurement context.
Example 2: Make exit direction depend on the latest navigation
A carousel needs more information than which slide is active. Moving forward should enter from the right and exit to the left. Moving backward should reverse those directions.
An exiting React element cannot receive a new ordinary prop after it has been removed from the tree. Motion solves this with a custom value on AnimatePresence and variant functions that accept that value. See the official custom behavior.
The example uses text slides so the transition can be understood without image loading or layout shifts.
'use client'
import { useState } from 'react'
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
const slides = ['Plan', 'Build', 'Review'] as const
type Direction = -1 | 1
const slideVariants = {
enter: (direction: Direction) => ({
x: direction * 48,
opacity: 0,
}),
center: { x: 0, opacity: 1 },
exit: (direction: Direction) => ({
x: direction * -48,
opacity: 0,
}),
}
export function DirectionalSlides() {
const [{ index, direction }, setPage] = useState({
index: 0,
direction: 1 as Direction,
})
function move(nextDirection: Direction) {
setPage(({ index }) => ({
index: (index + nextDirection + slides.length) % slides.length,
direction: nextDirection,
}))
}
return (
<MotionConfig reducedMotion="user">
<section aria-label="Project stages">
<div aria-live="polite" aria-atomic="true">
<AnimatePresence
initial={false}
custom={direction}
mode="wait"
>
<motion.p
key={index}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.2 }}
>
{slides[index]}
</motion.p>
</AnimatePresence>
</div>
<button type="button" onClick={() => move(-1)}>
Previous stage
</button>
<button type="button" onClick={() => move(1)}>
Next stage
</button>
</section>
</MotionConfig>
)
}
The state update keeps index and direction together. That avoids treating direction as unrelated state that might lag behind the index. A newly entered slide receives initial="enter", the current slide settles at center, and the exiting slide uses exit="exit" with the latest direction supplied to the presence boundary.
mode="wait" makes the incoming slide wait for the outgoing slide's exit. It supports only one child at a time. That makes sense for a single text panel, but not for a group of independently exiting cards. For overlapping slides, mode="sync" is another option; then you must handle their stacking and positioning yourself.
The two navigation buttons remain mounted, so keyboard focus stays on the control the user activated. The live region announces the changed text. If the slides were interactive panels instead, you would also need to decide how focus moves and prevent outgoing controls from being inadvertently reachable. For a real modal, use the accessible dialog focus contract, not an animation wrapper as a substitute.
When staggering helps, and when it becomes a delay
Parent variants can coordinate an initial sequence without assigning an individual timeout to every row:
'use client'
import { MotionConfig, motion } from 'motion/react'
const list = {
hidden: {},
visible: { transition: { staggerChildren: 0.05 } },
}
const item = {
hidden: { opacity: 0, y: 8 },
visible: { opacity: 1, y: 0 },
}
export function IntroList() {
return (
<MotionConfig reducedMotion="user">
<motion.ul variants={list} initial="hidden" animate="visible">
{['Design', 'Build', 'Verify'].map((label) => (
<motion.li key={label} variants={item}>
{label}
</motion.li>
))}
</motion.ul>
</MotionConfig>
)
}
The reduced-motion policy here matches the earlier examples. Motion suppresses transform animation for users requesting reduced motion, while opacity transitions may remain.
This is an initial reveal, not an appropriate default for every update to a live, changing list. If you stagger 40 items by 0.2 seconds, the last item starts 7.8 seconds after the first. That is a scheduling consequence of the specified delay, not evidence of a smoother interface. Keep important information available promptly and avoid making users wait for decorative sequencing.
Reduced motion and performance are part of the design
Each complete example uses <MotionConfig reducedMotion="user">. Under the documented policy, layout and transform animations are disabled when the user requests reduced motion, while properties such as opacity can still animate. If even a fade is distracting, shorten or remove it explicitly. The Motion accessibility guide also documents useReducedMotion for choosing alternative values per component.
Do not equate "uses transform" with "guaranteed 60 or 120 FPS." Motion's layout engine can use transform-based animation, but it still needs to measure layout changes. Images, paint-heavy effects, long JavaScript tasks, and the surrounding page can affect the result. Google's animation performance guide recommends looking at layout, paint, and compositing in DevTools, and using will-change sparingly rather than adding it to every animated element. For a broader diagnostic approach, see the browser rendering guide.
For a real implementation, validate the state transition, not only the screenshot:
- Filter rows twice and confirm the underlying row identity is stable.
- Navigate backward, forward, and rapidly across the carousel boundary; check the direction and the final content.
- Test
TabandShift+Tab: focus and control names must make sense with animation on and off. - Emulate
prefers-reduced-motion: reduceand verify the interface remains understandable without sliding or layout movement. - Record the interaction in the browser Performance panel. Investigate unexpected layout and paint work before attributing any improvement to the library.
Motion is most useful when it describes the relationship between UI states. Once that relationship is explicit, animation becomes easier to implement, easier to disable when necessary, and easier to debug when React renders something unexpected.
