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
transformandopacitycan 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-domfunction 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.
<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 { 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.
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.
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.
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
Slide from the other side
In the drawer example, change
translateX(-100%)totranslateX(100%)and setleft: auto; right: 0;. Predict whether you need to change any JavaScript. (No: the motion is all CSS.) - 2
Remove the starting style
In the toast example, delete the
@starting-styleblock and click Dismiss, then Show again. The toast still fades out, but now pops in with no fade. - 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
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
Console
Nothing logged yet.Expected console output
closed: inert = true
open: inert = false | class = drawer drawer--open
transitionend: transform | open = true50 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
Console
Nothing logged yet.Expected console output
50 ms after Dismiss, in DOM: true
transitionend: opacity -> unmount
600 ms after Dismiss, in DOM: falseEach 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
Console
Nothing logged yet.Expected console output
before: Mango · ₹120
inside callback, first item: Apple · ₹80
transition finished// 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);.
Break #2
Wait for a transitionend that never comes
In the toast example, delete the transition: ... line from .toast.
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.
- ▸
transitionenddoesn't fire if the transition is cancelled (the property changes again mid-way:transitioncancelfires instead), if the element is removed, or withdisplay: none. Robust exit code listens for both or uses a timeout fallback; Motion'sAnimatePresencehandles all of this for you. - ▸
Animating
height: autoused to need JavaScript measurement. Modern CSS offersinterpolate-size: allow-keywords(Chromium) and thegrid-template-rows: 0fr → 1frtrick; 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.transformdirectly inrequestAnimationFrame, or use CSS scroll-driven animations, instead of callingsetState60 times a second (Phase 16).
Remember this
- 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. Animatetransformandopacitywhere 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. Animatingwidth,heightortopmakes the browser redo layout every frame. The CSS course covers transitions, keyframes and performance in depth (/cssPhase 10). - 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@keyframesanimation (it runs whenever the element appears) or setting a class one frame after mounting. - 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 setsshowto false, which adds a leaving class and starts the transition; the element'sonTransitionEndhandler then setsmountedto false. Two traps:transitionendfires once per property (filter bye.propertyName), and it never fires if no transition runs (reduced motion,display: none, or a missing CSS rule), so always have a fallback. - 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 sameview-transition-namemorph to their new position and size). The callback must change the DOM synchronously, but React normally applies state updates a little later. That's whatflushSyncfromreact-domis for (Topic 8.6):flushSync(() => setItems(next))makes React commit before the callback returns. Feature-detect it withif (document.startViewTransition)and update normally otherwise. - 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 stablereactpackage, 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 }} />,AnimatePresencefor exit animations (it keeps leaving children mounted until theirexitanimation finishes, exactly the pattern above),layoutfor automatic layout animations, and gestures like drag. - 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 asmatchMedia("(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
How do you animate a change driven by React state with CSS? What should you animate for smoothness?
Why are exit animations hard in React and how do you solve them?
How do you use the View Transitions API with React, and why is flushSync needed?
What is prefers-reduced-motion and how should a React app respect it?
Practice
Write a useReducedMotion() hook that returns true when the user prefers reduced motion and updates when the setting changes.
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
transformandopacity.I can build an exit animation that unmounts after
transitionend, with a fallback.I can use
document.startViewTransitionwithflushSyncand feature detection.I know React's
<ViewTransition>is experimental in 19.2.I can use Motion's
AnimatePresencefor exit animations.I respect
prefers-reduced-motionin CSS and JavaScript.