I17 · Part 2 · Intermediate
Animation and transitions
CSS first, a library when you need interruption, and the one case React makes hard.
09On this page
The hard part is unmounting
Mounting animation is easy: the element appears, CSS animates it in. Unmounting is the problem. React removes the node the moment the condition flips, so there is nothing left to animate out.
{open && <Panel />} // close it and the node is gone this frame
Every animation library you have heard of exists mostly to solve this one thing: keep the element mounted a bit longer than the state says, then remove it. Everything else — springs, gestures, layout — is on top.
Level 1: CSS, which covers most of it
<div className={"panel " + (open ? "is-open" : "")}>…</div>
.panel { opacity: 0; transform: translateY(8px); transition: opacity .18s, transform .18s; }
.panel.is-open { opacity: 1; transform: none; }
The element is always mounted; only a class changes. Both directions animate,
it costs nothing, and it runs on the compositor. If the content must not be in
the DOM when hidden, add visibility: hidden at the end of the
transition — or reach for the next level.
Animate only two properties
transform and opacity are handled by the compositor
— no layout, no paint, and they keep running even when the main thread
is busy. Animating width, top, height
or margin re-runs layout on every frame of every animation on the
page. If you need a size change, animate scale and correct the
contents, or use grid-template-rows: 0fr → 1fr, which modern
browsers handle far better than height.
Level 2: keep it mounted yourself
function useDelayedUnmount(open, ms = 200) {
const [mounted, setMounted] = useState(open);
useEffect(() => {
if (open) { setMounted(true); return; }
const t = setTimeout(() => setMounted(false), ms);
return () => clearTimeout(t);
}, [open, ms]);
return mounted;
}
const mounted = useDelayedUnmount(open);
return mounted ? <div className={"panel " + (open ? "is-open" : "")}>…</div> : null;
Thirty lines less than a library and it is exactly enough for a modal, a dropdown or a toast. The timeout must match the CSS duration — a real coupling, and the reason people eventually move to level 3.
You can drop the guesswork with onTransitionEnd, but it fires per
property and does not fire at all if the element never got a chance to
transition. The timeout is more robust than it looks.
Level 3: a library, when the state can change mid-flight
The moment a user can close a panel while it is still opening, hand-rolled CSS starts fighting you: the class flips, the transition restarts from wherever it was, and reversals look wrong. That is what motion libraries buy you — interruptible, velocity-preserving animation.
import { AnimatePresence, motion } from "motion/react";
<AnimatePresence>
{open && (
<motion.div
key="panel"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
/>
)}
</AnimatePresence>
AnimatePresence is the delayed unmount, done properly: it holds
the child in the tree until exit finishes. The key is
what tells it "this is a different thing" — get it wrong and you get a
crossfade where you wanted a swap.
The layout animation trick worth knowing
Moving an element between two positions is FLIP — First, Last, Invert,
Play. Measure where it is, let it land in its new place, apply a transform
that puts it visually back where it was, then animate that transform to zero.
All of it in transform, so it stays on the compositor.
useLayoutEffect(() => {
const next = el.getBoundingClientRect();
const dx = prev.current.left - next.left;
const dy = prev.current.top - next.top;
if (dx || dy) {
el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }], {
duration: 200, easing: "ease-out",
});
}
prev.current = next;
});
useLayoutEffect is non-negotiable here — you must read the
new box and apply the inverse before the browser paints, or the element flashes
in its new spot first. Framer's layout prop is this, generalised.
View Transitions, and React's <ViewTransition>
The browser can snapshot the page before and after a DOM change and cross-fade
between them — including elements that moved, by matching
view-transition-name.
document.startViewTransition(() => { /* mutate the DOM */ });
Since React 19.3 (9 September 2026), <ViewTransition>
is stable. React calls document.startViewTransition() for you, at
the right moment in the commit, so you describe what should animate and
never time it by hand.
import { ViewTransition, startTransition } from "react";
function Gallery({ photo }) {
return (
<ViewTransition enter="slide-in" exit="fade-out">
<img src={photo.src} alt={photo.alt} />
</ViewTransition>
);
}
startTransition(() => setPhoto(next)); // ✓ animates
setPhoto(next); // ✗ a plain update does not
What makes it animate
Only updates React treats as transitions: startTransition,
useTransition, useDeferredValue, and content
resolving inside <Suspense>. An ordinary
setState does not, and flushSync skips it on purpose.
This is a feature — a keystroke should not cross-fade.
| Prop | Runs when |
|---|---|
enter | The boundary is inserted in a transition |
exit | The boundary is removed in a transition |
update | Something inside changes, or it moves or resizes |
share | A named boundary leaves in one place and appears in another |
default | The fallback — "none" turns everything off unless listed |
Each takes "auto" (the browser's cross-fade), "none", or
a class name you style with ::view-transition-old(.slide-in) and
::view-transition-new(.slide-in). There are matching
onEnter, onExit, onUpdate and
onShare callbacks for Web Animations; each must return a cleanup
that cancels the animation.
The placement rule that causes most bugs
// ✓ ViewTransition comes before any DOM node
<ViewTransition enter="auto"><div>...</div></ViewTransition>
// ✗ a <div> above it means enter and exit never fire
<div><ViewTransition enter="auto"><div>...</div></ViewTransition></div>
Different animations for different transitions
import { addTransitionType } from "react";
startTransition(() => {
addTransitionType(direction === "back" ? "nav-back" : "nav-forward");
navigate(to);
});
<ViewTransition default={{ "nav-back": "slide-right", "nav-forward": "slide-left" }}>
{page}
</ViewTransition>
Shared element transitions
<ViewTransition name={`photo-${id}`}><Thumbnail /></ViewTransition>
// ...and in the detail view, rendered in the same transition:
<ViewTransition name={`photo-${id}`}><FullImage /></ViewTransition>
Two boundaries with the same name, one unmounting and one mounting
in the same transition, morph into each other — the thumbnail grows into
the full image. Only one boundary with a given name may be mounted at a time,
and if either side is off-screen the pair does not form, so it falls back to a
plain enter and exit.
It works on the DOM only, not React Native. Effects wait until the animation finishes, and updates that arrive mid-animation are batched into one: A to B, then C, then D animates as B to D.
The accessibility line
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
transition-duration: .01ms !important;
}
}
// and in JS, when a library needs telling
const QUERY = "(prefers-reduced-motion: reduce)";
const reduced = useSyncExternalStore(
(cb) => {
const m = matchMedia(QUERY);
m.addEventListener("change", cb);
return () => m.removeEventListener("change", cb);
},
() => matchMedia(QUERY).matches,
() => false, // server: assume motion is fine, CSS corrects it
);
Reduce, do not remove. An instant state change with no motion at all can be more disorienting than a slow one; a fast opacity fade is the usual compromise.
Where it goes wrong
| Symptom | Cause |
|---|---|
| Enter animation never plays | The element mounts already in its final state — there was no frame at the start value. Mount with the initial class, flip on the next frame. |
| Exit animation never plays | Nothing is holding the node in the tree. |
| Janky while a list renders | You animated a layout property, or the main thread is blocked. Check the Performance panel for purple layout bars. |
| Reversal jumps | CSS transitions restart from the current computed value but with the full duration. Springs handle this; transitions do not. |
| Animates on first paint after SSR | The server HTML has the final class already. Gate on a mounted flag. |
| Whole list re-animates on any change | Keys changed — usually an index key, or a new array identity feeding a keyed AnimatePresence. |
Choosing
| Need | Reach for |
|---|---|
| Hover, focus, a class flip | CSS transition. Always. |
| Modal or dropdown in and out | CSS + a delayed unmount hook |
| Interruptible, gesture-driven, springs | Motion (Framer), or the Web Animations API directly |
| Elements moving between layouts | FLIP, or layout props |
| Page or route transitions | View Transitions API |
| Timeline-heavy, scroll-driven storytelling | GSAP, or CSS scroll-driven animations |
Interview line
"React does not animate — it mounts and unmounts. Enter is CSS; exit needs something to keep the node alive past the state change. I do that with a small delayed-unmount hook, and reach for a motion library only when the animation has to be interruptible."
Interview answer, one sentence
"CSS covers most animation if you stick to transform and opacity; the hard part in React is animating out, because the node is removed the moment state flips, so keep it mounted until the transition ends — by hand or with a library — and honour prefers-reduced-motion."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series