Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~27 min· topic 6 of 6

Topic 11.6

Animation and Transitions

In one line

In React you animate by changing state and letting CSS transitions move between the old and new styles; exits need the element kept mounted until its animation ends, the View Transitions API animates whole DOM changes, the Motion library handles complex cases, and every animation must respect prefers-reduced-motion.

Think of it like this

A theatre curtain. The director only says "curtain open" or "curtain closed" (the state). The stagehands know how to move it smoothly between the two (the transition). And when an actor leaves, they walk off the stage first; they don't vanish in the middle of a line. In React, your state says *what* the screen is; CSS says *how* it moves from one state to the next; and leaving elements need time to walk off before React removes them.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

CSS transition
Tells the browser to animate a property smoothly when its value changes, like transition: opacity 300ms.
Compositor
The part of the browser that combines painted layers, often on the GPU. Animating transform and opacity can happen there cheaply.
@starting-style
A CSS rule that gives a newly shown element a starting style, so it can transition in.
transitionend
A DOM event fired when a transition finishes, once per property. React's prop is onTransitionEnd.
Exit animation
An animation that plays as an element leaves, before it is removed from the page.
View Transitions API
A browser feature that animates between the page before and after a DOM change: document.startViewTransition.
flushSync
A react-dom function that makes React apply a state update to the DOM immediately.
Motion
A popular React animation library (formerly Framer Motion) with exit, layout and gesture animations.
prefers-reduced-motion
A user setting, readable from CSS and JavaScript, asking apps to minimise movement.

Step by step

01State picks the class, CSS animates

The component only flips open. The transition lives in the stylesheet, and the reduced-motion rule turns it off for people who asked.

Drawer.jsx + drawer.csswhole filejsx
<nav className={open ? "drawer drawer--open" : "drawer"} inert={!open}>…</nav>

/* drawer.css */
.drawer { transform: translateX(-100%); transition: transform 250ms ease-out; }
.drawer--open { transform: translateX(0); }
@media (prefers-reduced-motion: reduce) {
  .drawer { transition: none; }
}

02Entering: give the first frame a starting style

Without a starting point the element appears at its final style. @starting-style (or a keyframes animation) provides one.

toast.csswhole filecss
.toast { opacity: 1; transform: none; transition: opacity 300ms, transform 300ms; }
@starting-style {
  .toast { opacity: 0; transform: translateY(8px); }
}
.toast--leaving { opacity: 0; transform: translateY(8px); }

03Leaving: keep it mounted until the transition ends

Split visibility from presence. React removes the element only after transitionend, or straight away when no transition will run.

Leaving: keep it mounted until the transition endsdiagram
Rendering diagram…

04Whole-page changes: startViewTransition + flushSync

The browser captures the old view, runs your callback, captures the new view and animates between them. flushSync makes React's DOM change happen inside the callback.

SortableList.jsxwhole filejsx
import { flushSync } from "react-dom";

function sortBy(key) {
  const next = [...items].sort(byKey(key));
  if (!document.startViewTransition) return setItems(next);
  document.startViewTransition(() => {
    flushSync(() => setItems(next));
  });
}

// each row: style={{ viewTransitionName: "item-" + item.id }}

05Complex motion: the Motion library

AnimatePresence notices when a child disappears from the JSX and keeps it rendered until its exit animation finishes. MotionConfig reducedMotion="user" follows the operating-system setting for every animation inside.

Toast.jsx (Motion)whole filejsx
import { AnimatePresence, MotionConfig, motion } from "motion/react";

export function Toasts({ open }) {
  return (
    <MotionConfig reducedMotion="user">
      <AnimatePresence>
        {open && (
          <motion.div
            key="saved"
            role="status"
            initial={{ opacity: 0, y: 8 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 8 }}
          >
            Saved!
          </motion.div>
        )}
      </AnimatePresence>
    </MotionConfig>
  );
}

Try it yourself

  1. 1

    Slide from the other side

    In the drawer example, change translateX(-100%) to translateX(100%) and set left: auto; right: 0;. Predict whether you need to change any JavaScript. (No: the motion is all CSS.)

  2. 2

    Remove the starting style

    In the toast example, delete the @starting-style block and click Dismiss, then Show again. The toast still fades out, but now pops in with no fade.

  3. 3

    Turn on reduced motion

    In Chrome DevTools, open the Rendering panel and set Emulate CSS media feature prefers-reduced-motion to reduce. Rerun all three examples. Everything still works; only the movement is gone.

Code & diagrams

A CSS transition driven by state New tab

React only changes the class and the `inert` attribute; the browser animates `transform` and `opacity`. `inert` keeps the hidden links out of the Tab order while the drawer is closed (an off-screen element is still focusable otherwise). The handler checks `propertyName` because `transitionend` fires once per animated property.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

closed: inert = true
open: inert = false | class = drawer drawer--open
transitionend: transform | open = true
Exit animation: keep it mounted until the transition ends New tab

50 ms after Dismiss the toast is still in the DOM, fading out; React removes it only when the opacity transition ends. Click **Show again**: `@starting-style` makes it fade in. With reduce motion switched on in your system, `dismiss` unmounts at once, because no `transitionend` would ever come.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

50 ms after Dismiss, in DOM: true
transitionend: opacity -> unmount
600 ms after Dismiss, in DOM: false
View Transitions with flushSync (feature-detected) New tab

Each row has its own `view-transition-name`, so the browser slides every row to its new place. Thanks to `flushSync`, the DOM is already sorted inside the callback. In a browser without the API the list still sorts, just without animation.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

before: Mango · ₹120
inside callback, first item: Apple · ₹80
transition finished
React's <ViewTransition>: experimental, not in stable React 19.2jsx
// Only in React's canary/experimental channels (npm install react@canary react-dom@canary).
// The stable "react" package (19.2) does not export it. The API may still change.
// It is exported with an unstable_ prefix while experimental.
import { unstable_ViewTransition as ViewTransition } from "react";

<ViewTransition>
  <Page key={url} />
</ViewTransition>

// React would call startViewTransition for you when this content changes inside
// a Transition (startTransition, useDeferredValue, Suspense reveals).
// Today in stable React: document.startViewTransition + flushSync, or Motion.

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Start a view transition without flushSync

In the View Transitions example, change flushSync(() => setItems(next)); to setItems(next);.

terminal
$ Console
── what you'll see ──
before: Mango · ₹120
inside callback, first item: Mango · ₹120
transition finished

Break #2

Wait for a transitionend that never comes

In the toast example, delete the transition: ... line from .toast.

terminal
$ Console
── what you'll see ──
50 ms after Dismiss, in DOM: true
600 ms after Dismiss, in DOM: true

Myth vs fact

Myth

Animation in React needs an animation library.

Fact

Most UI motion is a CSS transition between two classes chosen by state. Reach for Motion when you need exit animations for lists, layout animations, gestures or physics.

Myth

<ViewTransition> is part of React 19.

Fact

It is experimental (canary and experimental channels only) as of React 19.2. The browser's document.startViewTransition works with stable React today.

Myth

Reduced motion means no animation at all.

Fact

It means reducing movement that can cause discomfort: slides, zooms, parallax, autoplay. Gentle fades and colour changes are usually fine.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    transitionend doesn't fire if the transition is cancelled (the property changes again mid-way: transitioncancel fires instead), if the element is removed, or with display: none. Robust exit code listens for both or uses a timeout fallback; Motion's AnimatePresence handles all of this for you.

  • ▸

    Animating height: auto used to need JavaScript measurement. Modern CSS offers interpolate-size: allow-keywords (Chromium) and the grid-template-rows: 0fr → 1fr trick; check browser support before relying on them.

  • ▸

    View transition snapshots are images: during the animation the page isn't interactive. Keep durations short. Same-document view transitions are supported in Chromium, Safari 18 and recent Firefox; cross-document ones (@view-transition { navigation: auto; }) are for multi-page sites.

  • ▸

    React state updates during an animation cause re-renders; for animations driven every frame (drag, scroll-linked), write to a ref and set style.transform directly in requestAnimationFrame, or use CSS scroll-driven animations, instead of calling setState 60 times a second (Phase 16).

Remember this

  1. 1

    A CSS transition animates a property from its old value to its new value whenever it changes: transition: transform 250ms ease-out. React fits this perfectly: state changes a class (drawer--open) or a CSS variable, React updates the attribute, and the browser animates. You write no animation code in JavaScript. Animate transform and opacity where you can: the browser can move them on the compositor (the part that combines layers on the GPU) without recalculating layout, so they stay smooth. Animating width, height or top makes the browser redo layout every frame. The CSS course covers transitions, keyframes and performance in depth (/css Phase 10).

  2. 2

    Mounting animations (entering): a new element has no "old value" to transition from, so a plain transition does nothing on mount. Modern CSS fixes this with @starting-style { .toast { opacity: 0; } }, which gives the browser a starting point for the first style. Alternatives are a CSS @keyframes animation (it runs whenever the element appears) or setting a class one frame after mounting.

  3. 3

    Unmounting animations (leaving) are the hard part. When state says the toast is gone, React removes the element immediately, so there is nothing left to animate. The fix is to separate two ideas: *should it be visible* (show) and *is it in the DOM* (mounted). Hiding sets show to false, which adds a leaving class and starts the transition; the element's onTransitionEnd handler then sets mounted to false. Two traps: transitionend fires once per property (filter by e.propertyName), and it never fires if no transition runs (reduced motion, display: none, or a missing CSS rule), so always have a fallback.

  4. 4

    The View Transitions API animates a whole DOM change at once. You call document.startViewTransition(callback); the browser takes a screenshot of the page, runs your callback to change the DOM, then animates from the old picture to the new one (a cross-fade by default; elements with the same view-transition-name morph to their new position and size). The callback must change the DOM synchronously, but React normally applies state updates a little later. That's what flushSync from react-dom is for (Topic 8.6): flushSync(() => setItems(next)) makes React commit before the callback returns. Feature-detect it with if (document.startViewTransition) and update normally otherwise.

  5. 5

    **React's own <ViewTransition> component wraps this API in a declarative way, integrated with transitions and Suspense. As of React 19.2 it is experimental**: it exists only in React's canary and experimental release channels and is not exported by the stable react package, so don't use it in production code yet. For complex animation today, the Motion library (npm install motion, formerly Framer Motion) is the common choice: <motion.div animate={{ opacity: 1 }} />, AnimatePresence for exit animations (it keeps leaving children mounted until their exit animation finishes, exactly the pattern above), layout for automatic layout animations, and gestures like drag.

  6. 6

    **prefers-reduced-motion**: some people get dizzy, nauseous or distracted from movement on screen (for example people with vestibular disorders), and they switch on a "reduce motion" setting in their operating system. CSS sees it as @media (prefers-reduced-motion: reduce) and JavaScript as matchMedia("(prefers-reduced-motion: reduce)"). Respect it: replace big movements with a fade or an instant change, stop auto-playing and looping animations, and make sure your code still works when no transition runs. Hectal's live examples include it in their CSS; WCAG (the web accessibility guidelines) asks for this in criterion 2.3.3.

Explain it without notes

01

How do you animate a change driven by React state with CSS? What should you animate for smoothness?

02

Why are exit animations hard in React and how do you solve them?

03

How do you use the View Transitions API with React, and why is flushSync needed?

04

What is prefers-reduced-motion and how should a React app respect it?

Practice

01

Write a useReducedMotion() hook that returns true when the user prefers reduced motion and updates when the setting changes.

02

Make a Collapse component that slides a panel open and closed with a CSS transition on grid-template-rows, with aria-expanded on its toggle button.

Trade-offs

  • ↔

    CSS transitions are free of JavaScript and run smoothly, but exits and coordinated list animations need extra state; Motion handles those at the cost of a dependency (tens of KB).

  • ↔

    View transitions give impressive whole-page effects with little code, but need flushSync (which skips React's batching), freeze interaction while running and still need a fallback for older browsers.

Done when you can

  • I can animate a state change with a CSS transition on transform and opacity.

  • I can build an exit animation that unmounts after transitionend, with a fallback.

  • I can use document.startViewTransition with flushSync and feature detection.

  • I know React's <ViewTransition> is experimental in 19.2.

  • I can use Motion's AnimatePresence for exit animations.

  • I respect prefers-reduced-motion in CSS and JavaScript.