Animating React Interfaces: Transitions, State, and Motion
Use CSS transitions and CSS animations effectively from React, coordinate animation with component state, implement correct enter/exit (mount/unmount) animations, understand layout animation techniques, respect prefers-reduced-motion, and reason about animation performance and when to bring in a library.
Motion in an interface is not decoration — a well-timed transition tells the user what changed, where it came from, and where their attention should go next. But animation is also one of the easiest places to introduce jank, accessibility problems, and confusing UI, especially in React where the framework's declarative re-render model does not naturally know how to animate an element that is about to be removed from the DOM entirely. This lesson works from plain CSS transitions up through the specific React problem of animating unmounting elements, then covers layout animation, the reduced-motion media query, performance fundamentals, and where dedicated animation libraries earn their keep.
CSS transitions: the foundation
A CSS transition animates a property from its old computed value to its new one whenever that value changes, as long as the change happens while the element is already in the DOM and the transition property is declared. transition: transform 200ms ease, opacity 200ms ease; means: whenever transform or opacity changes on this element (via a class toggle, an inline style change, or a CSS variable update), animate it over 200ms using an ease timing curve rather than snapping instantly.
In React, transitions are triggered simply by changing the style or className that drives the transitioning property between renders — no special API is needed for the 'already mounted' case. The classic pattern is a boolean state flag toggling a class: className={isOpen ? 'panel panel--open' : 'panel'}, where .panel--open sets opacity: 1 and .panel (unopened) sets opacity: 0, with the transition declared on the base .panel rule.
The property choice matters enormously for performance: animating transform and opacity can run on the compositor thread without triggering layout or paint on every frame, while animating width, height, top, left, or margin forces the browser to recompute layout on each frame, which is far more expensive and often visibly janky under load.
CSS animations: @keyframes for multi-step or looping motion
CSS transitions only interpolate between two states (before and after a change). When you need multiple intermediate steps, a loop, or motion that starts on its own rather than in response to a property change (like a pulsing skeleton loader), @keyframes-based CSS animations are the right tool. You define named keyframes with percentage-based steps, then apply them with the animation shorthand property (name, duration, timing function, iteration count, fill mode).
A subtle but important detail: animation-fill-mode: forwards keeps the element in its final keyframe state after the animation completes, rather than snapping back to its pre-animation styles — essential for animations meant to leave a permanent visual change (like a checkmark that draws in and stays).
Coordinating animation with React state
The hard problem in React animation is timing: you often need to change a class after the initial render (to trigger a transition from a starting state) or wait for an animation to finish before changing state again (like removing an element from the DOM only after its exit animation completes). A common pattern for a 'mount and immediately transition in' effect uses a double state flag: render the element in its initial (pre-transition) state on the first paint, then flip a second boolean to true in a useEffect so the transition is observed by the browser as an actual property change rather than an instant jump.
For knowing when a CSS transition or animation finishes, the DOM gives you the transitionend and animationend events, which fire once per animated property (so a transition on both transform and opacity fires transitionend twice — code that reacts to it should check event.propertyName or use a counter). React exposes these as ordinary DOM event props: onTransitionEnd and onAnimationEnd.
Enter/exit animations: the unmounting problem
React removes a component from the DOM the instant its condition becomes false — {isVisible && <Panel />} unmounts Panel synchronously on the render where isVisible flips to false, with no opportunity for an exit CSS transition to play, because the element is already gone before the browser can animate it out.
The standard solution is to keep the element mounted for the duration of its exit animation by tracking a separate 'should render' state that lags one animation-duration behind the 'should be visible' state, then unmounting only after transitionend (or a matching setTimeout) fires. This is precisely the problem the react-transition-group library's <CSSTransition> component and Framer Motion's <AnimatePresence> component solve for you — they keep an element mounted through its exit phase, apply enter/exit/appear classes (or animate props) at the right moments, and remove the element from the DOM only once the animation genuinely completes.
Layout animation
Layout animation refers to smoothly animating an element as its position or size changes due to layout, not just an explicit style change — for example, when an item is removed from a list and the remaining items need to slide up smoothly to fill the gap, rather than jumping instantly. Because raw layout properties (top/left/width/height) are expensive to animate, the standard technique is the FLIP method: record the element's First position, let the DOM update to its Last position (a free, instant browser layout), compute the Invert transform that would visually put it back at its First position, apply that transform with transitions disabled, then remove the transform and enable a transition on transform only — the browser animates a cheap, compositor-only transform from the inverted position to identity, which looks identical to animating layout directly but runs far more efficiently.
Libraries like Framer Motion automate FLIP via the layout prop on a motion component, which measures before/after positions on every render and animates the difference automatically — genuinely difficult to hand-roll correctly for arbitrary layout changes, which is one of the strongest reasons to reach for a library specifically for this category of animation.
Respecting prefers-reduced-motion
Some users experience real discomfort — dizziness, nausea, migraine triggers — from large-scale motion, parallax, or auto-playing animation, and set a system-level 'reduce motion' preference to communicate this. The CSS media query @media (prefers-reduced-motion: reduce) lets you detect this and either disable or drastically shorten transitions and animations for those users, and in JavaScript window.matchMedia('(prefers-reduced-motion: reduce)').matches exposes the same signal for animation logic driven by state rather than pure CSS.
Good practice is not necessarily to remove all motion for these users but to prefer opacity-only crossfades over large positional or scaling motion, and to make auto-playing or looping animations (carousels, background video, parallax) pausable or entirely disabled by default when the preference is set.
Animation performance
The performance rule that governs almost everything above: the browser's compositor thread can animate transform and opacity changes independently of the main thread, running at a steady frame rate even while JavaScript is busy elsewhere, because these two properties can be handled purely by the GPU without recalculating layout or repainting the surrounding page. Any other animated property — width, height, top, left, margin, padding, even box-shadow in many cases — forces layout recalculation (reflow) and/or repaint on every animation frame, which is dramatically more expensive and the most common cause of janky, dropped-frame animation in real applications.
will-change: transform is a hint you can add just before a heavy animation begins (and remove afterward) to ask the browser to promote the element to its own compositor layer in advance, avoiding a visible hitch on the animation's first frame — but it should be used sparingly, since keeping many elements permanently promoted to their own layers consumes GPU memory and can itself hurt performance.
Animation libraries: when hand-rolled CSS is not enough
For straightforward hover states, toggled panels, and simple enter/exit fades, plain CSS transitions driven by React state are usually the right, lightest-weight choice — no dependency, no extra bundle size, and the browser's own compositor does the heavy lifting. Reach for a library when you need declarative enter/exit orchestration across many components (Framer Motion's AnimatePresence), physics-based spring motion that feels more natural than a fixed-duration curve, gesture-driven animation (drag, tap, hover) tied directly to spring physics, shared-element transitions between routes, or automatic FLIP-style layout animation.
Framer Motion (recently renamed Motion) is the dominant choice in the React ecosystem for this, offering a motion.div drop-in replacement for any element with animate, exit, and layout props; react-spring offers a more explicitly physics-first API; and GSAP remains popular for complex timeline-based sequences, especially outside of pure React contexts. As with drag and drop, the value of understanding the underlying CSS transition/animation model first is that you can read what any of these libraries produce under the hood, debug it in DevTools, and know when reaching for one is actually justified rather than reflexive.
Enter/exit pattern with delayed unmount
requestAnimationFrame (rather than setting entered immediately) ensures the initial pre-transition style is actually painted by the browser before the transitioning style is applied The setTimeout duration must match the CSS transition-duration exactly, or listen for transitionend instead to avoid drift This hook generalizes what react-transition-group's Transition component does internally
function useExitTransition(shouldRender: boolean, durationMs: number) {
const [mounted, setMounted] = useState(shouldRender);
const [entered, setEntered] = useState(false);
useEffect(() => {
if (shouldRender) {
setMounted(true);
// flip 'entered' on the next frame so the browser observes a real transition
const id = requestAnimationFrame(() => setEntered(true));
return () => cancelAnimationFrame(id);
} else {
setEntered(false);
const timeout = setTimeout(() => setMounted(false), durationMs);
return () => clearTimeout(timeout);
}
}, [shouldRender, durationMs]);
return { mounted, entered };
}Code Examples
This transitions max-height (measured from the content's real scrollHeight) rather than height: auto directly, since CSS transitions cannot interpolate to or from the auto keyword. The chevron icon transitions transform, staying on the cheap compositor-only path. max-height animation is a well-known workaround for the 'can't transition to auto' limitation; it does force layout recalculation each frame (since max-height affects box size), so for very tall or frequently toggled content, prefer a library's measured-height or FLIP-based approach instead.
transition: max-height 250ms ease ToastItem stays mounted through its full exit transition because removal from the toasts array only happens inside onExited, which is wired to the real transitionend event rather than an assumed timeout — so the exit animation always completes even if the browser is under load and running slightly behind schedule.
onExited: () => void Common mistake: Removing the item from state immediately on the dismiss button click (setToasts(t => t.filter(...)) directly in the onClick) unmounts ToastItem synchronously, so the fade/slide-out never has a chance to render at all.
Under reduced motion, the positional slide is removed entirely (transform: none) and only a brief opacity crossfade remains, which communicates the same state change without the larger motion that can trigger discomfort for sensitive users.
@media (prefers-reduced-motion: reduce) {
function CollapsiblePanel({ title, children }: { title: string; children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [maxHeight, setMaxHeight] = useState(0);
useEffect(() => {
if (isOpen && contentRef.current) {
setMaxHeight(contentRef.current.scrollHeight);
} else {
setMaxHeight(0);
}
}, [isOpen, children]);
return (
<div>
<button
onClick={() => setIsOpen((o) => !o)}
aria-expanded={isOpen}
style={{ display: "flex", justifyContent: "space-between", width: "100%" }}
>
{title}
<span
style={{
transform: isOpen ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 200ms ease",
}}
>
▾
</span>
</button>
<div
style={{
maxHeight,
overflow: "hidden",
transition: "max-height 250ms ease",
}}
>
<div ref={contentRef} style={{ padding: "8px 0" }}>
{children}
</div>
</div>
</div>
);
}const EXIT_DURATION = 200;
function ToastItem({
message,
onExited,
}: {
message: string;
onExited: () => void;
}) {
const [phase, setPhase] = useState<"entering" | "entered" | "exiting">(
"entering"
);
useEffect(() => {
const raf = requestAnimationFrame(() => setPhase("entered"));
return () => cancelAnimationFrame(raf);
}, []);
const startExit = () => setPhase("exiting");
return (
<div
onTransitionEnd={() => {
if (phase === "exiting") onExited();
}}
style={{
opacity: phase === "entered" ? 1 : 0,
transform:
phase === "entered" ? "translateY(0)" : "translateY(-8px)",
transition: "opacity 200ms ease, transform 200ms ease",
}}
>
{message}
<button onClick={startExit} aria-label="Dismiss">
×
</button>
</div>
);
}
function ToastStack() {
const [toasts, setToasts] = useState<{ id: number; message: string }[]>([]);
const remove = (id: number) =>
setToasts((t) => t.filter((toast) => toast.id !== id));
return (
<div>
{toasts.map((toast) => (
<ToastItem
key={toast.id}
message={toast.message}
on
….modal {
transform: translateY(24px);
opacity: 0;
transition: transform 300ms ease, opacity 300ms ease;
}
.modal--open {
transform: translateY(0);
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.modal {
transform: none;
transition: opacity 150ms ease;
}
}The FLIP technique for cheap layout animation
FLIP turns an expensive layout animation into a cheap transform animation by exploiting the fact that transform changes are free to animate on the compositor.
First Last (after DOM update, Invert (apply transform
(before change) browser computed instantly) to visually undo the move)
┌──────┐ ┌──────┐ ┌──────┐ ← transform:
│ item │ │ │ │ item │ translate(dx,dy)
└──────┘ ├──────┤ └──────┘ no transition
┌──────┐ → │ item │ → ┌──────┐
│ │ └──────┘ │ │
└──────┘ └──────┘
│
▼ Play: remove the
transform WITH a
transition enabled —
browser animates
transform → identity,
compositor-onlyImportant Rules
Prefer animating transform and opacity over layout-triggering properties (width, height, top, left, margin) whenever possible.
An unmounting React element gets zero chance to animate out — keep it mounted through its exit transition and remove it only after transitionend/animationend fires.
You cannot transition to or from the CSS auto keyword directly; use a measured pixel value (e.g. scrollHeight) or a library's measured-layout approach instead.
Always provide an @media (prefers-reduced-motion: reduce) rule for any transition or animation involving significant positional or scaling motion.
Use requestAnimationFrame (not a synchronous state flip) to trigger a 'starting state to ending state' transition on first mount, so the browser actually observes the initial paint before the change.
Reach for a library (Framer Motion, react-spring) for cross-component enter/exit orchestration, physics-based motion, or automatic layout animation — these are hard to hand-roll correctly.
Toast notifications and modal dialogs
Toast/snackbar stacks and modal dialogs are the two UI patterns developers hit the exit-animation problem on almost immediately, because both are conditionally rendered and both are expected to animate out gracefully rather than vanish instantly. Production design systems (Radix UI's Dialog and Toast primitives, for example) solve this exact problem internally by exposing a data-state attribute ('open' | 'closed') that stays present during the exit phase, letting consumer CSS transition against data-state=closed before the component actually unmounts — the same delayed-unmount pattern covered in this lesson, just packaged behind a reusable primitive.
{isVisible && <Modal />} with a CSS transition defined on Modal, expecting an exit fade to play.
Keep Modal mounted (via a lagging 'shouldRender' state or a library) until its exit transition's transitionend fires, then unmount.
React removes the element from the DOM the instant isVisible becomes false — the browser is never given a chance to animate a property change on an element that no longer exists.
Setting isOpen state to true and applying the 'open' class in the exact same synchronous render as when the element first mounts.
Mount in the closed/start state, then flip to the open state in a useEffect (ideally via requestAnimationFrame) on a subsequent frame.
If both the initial and final styles are set before the browser's first paint, there is no observed property change to transition from, and the element just appears already in its final state.
Animating width/height/top/left directly for a list-reorder or resize effect.
Animate a transform (translate/scale) computed via the FLIP technique, or use a library's automatic layout animation.
Layout properties force the browser to recompute page layout on every animation frame, which is far more expensive than compositor-only transform changes and is the most common source of janky animation.
Why does {isVisible && <Panel />} fail to play an exit (fade-out) CSS transition on Panel when isVisible becomes false? A. CSS transitions are disabled by default in React's StrictMode B. React removes Panel from the DOM synchronously on that render, giving the browser no property change to animate before the element is gone C. The transition property was declared on the wrong element D. Exit transitions only work with CSS @keyframes animations, never with transitions
B. React removes Panel from the DOM synchronously on that render, giving the browser no property change to animate before the element is gone — A CSS transition animates a change in a still-present element's computed style; once React unmounts the element there is nothing left in the DOM to animate, which is why exit animations require keeping the element mounted until the transition finishes.
Which pair of CSS properties can typically be animated on the browser's compositor thread without triggering layout recalculation? A. width and height B. top and left C. transform and opacity D. margin and padding
C. transform and opacity — transform and opacity changes can be handled by the compositor/GPU independently of the main thread's layout and paint work, making them the cheapest properties to animate smoothly; the other properties all force layout recalculation on each animated frame.
What does the prefers-reduced-motion: reduce media query let you do? A. Automatically disable all JavaScript event handlers on a page B. Detect a user's OS-level preference to minimize large-scale or triggering motion, and adjust or remove animations accordingly C. Force all CSS transitions to run at half speed globally D. Prevent screen readers from announcing animated content
B. Detect a user's OS-level preference to minimize large-scale or triggering motion, and adjust or remove animations accordingly — prefers-reduced-motion reflects a system accessibility setting some users enable because significant motion can cause discomfort or trigger symptoms; well-built interfaces reduce or remove large positional/scaling animation for these users, often keeping a brief opacity crossfade instead.
Build a Modal component that fades and scales in on open, fades and scales out on close (using the delayed-unmount pattern so the exit animation always completes), and falls back to an opacity-only crossfade with no scale motion when prefers-reduced-motion is set.
Implement the mounted/entered phase state exactly as in useExitTransition, drive opacity and transform: scale(...) from the phase value, and add a second CSS rule under @media (prefers-reduced-motion: reduce) that overrides the transform to none and shortens the transition — no JavaScript detection is required since the media query handles it declaratively in CSS.
Read window.matchMedia('(prefers-reduced-motion: reduce)').matches once (or subscribe to its change event) to pick between two className variants, or use CSS's @media query directly in your stylesheet so you don't need any JavaScript branching at all.