Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I17
28 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛I17 · Part 2 · Intermediate

Animation and transitions

CSS first, a library when you need interruption, and the one case React makes hard.

6 min read9 sections2 exercises
28 of 57

In this chapter

  • The hard part is unmounting
  • Level 1: CSS, which covers most of it
  • Level 2: keep it mounted yourself
  • Level 3: a library, when the state can change mid-flight
  • The layout animation trick worth knowing

and 4 more

about 6 min
09On this page
  1. The hard part is unmounting
  2. Level 1: CSS, which covers most of it
  3. Level 2: keep it mounted yourself
  4. Level 3: a library, when the state can change mid-flight
  5. The layout animation trick worth knowing
  6. View Transitions, and React's ViewTransition
  7. The accessibility line
  8. Where it goes wrong
  9. Choosing

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.

PropRuns when
enterThe boundary is inserted in a transition
exitThe boundary is removed in a transition
updateSomething inside changes, or it moves or resizes
shareA named boundary leaves in one place and appears in another
defaultThe 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

SymptomCause
Enter animation never playsThe 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 playsNothing is holding the node in the tree.
Janky while a list rendersYou animated a layout property, or the main thread is blocked. Check the Performance panel for purple layout bars.
Reversal jumpsCSS transitions restart from the current computed value but with the full duration. Springs handle this; transitions do not.
Animates on first paint after SSRThe server HTML has the final class already. Gate on a mounted flag.
Whole list re-animates on any changeKeys changed — usually an index key, or a new array identity feeding a keyed AnimatePresence.

Choosing

NeedReach for
Hover, focus, a class flipCSS transition. Always.
Modal or dropdown in and outCSS + a delayed unmount hook
Interruptible, gesture-driven, springsMotion (Framer), or the Web Animations API directly
Elements moving between layoutsFLIP, or layout props
Page or route transitionsView Transitions API
Timeline-heavy, scroll-driven storytellingGSAP, 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."

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsThe phases of a delayed unmountintermediate
  • 5 testsExit, then unmount — never the other way roundintermediate
All 66 for this level →
I17

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I16Previous · I16TypeScript with React
  2. I17you are here
  3. Next · I18End-to-end testingThe tests that run the real app in a real browser — and why you want few of them.4 min read

01On this page

9 sections · 0%

  1. 01The hard part is unmounting
  2. 02Level 1: CSS, which covers most of it
  3. 03Level 2: keep it mounted yourself
  4. 04Level 3: a library, when the state can change mid-flight
  5. 05The layout animation trick worth knowing
  6. 06View Transitions, and React's ViewTransition
  7. 07The accessibility line
  8. 08Where it goes wrong
  9. 09Choosing

[] chapters · t top