View transitions
Animate between screens with the browser's View Transition API and React's ViewTransition: what triggers it, custom classes, types, and shared elements.
- 01Animate a page change with the browserCall
document.startViewTransition, read its promises, style its pseudo-elements, and fall back when a browser doesn't have it. - 02Animate React updates with ViewTransitionWrap part of the page in
<ViewTransition>, and know which updates animate it: transitions, Suspense reveals, and deferred values. - 03Choose the animationGive enter, exit, and update animations your own CSS classes, pick them by cause with
addTransitionType, move a shared element withname, and respect reduced motion.
A page that changes in a single frame
When you click a link or a tab on most pages, the old content is gone and the new content is there, from one frame to the next. For a small change, like a number, that's right. When a whole panel or page swaps, it's easy to lose your place: what moved, and where did it go? A short animation between the two, like a fade or a slide, shows the change.
Browsers have a built-in way to do this, the View Transition API. Here is a shop page with two buttons that do the same thing: switch between the teas and your cart. The result is live: click each button a few times and compare.
const pages = { teas: "<h3>Teas</h3><p>Green tea, chai, mint tea.</p>", cart: "<h3>Your cart</h3><p>2 items: chai and mint tea. Total: $7</p>",};let current = "teas"; function switchPage() { current = current === "teas" ? "cart" : "teas"; document.querySelector("#page").innerHTML = pages[current];} document.querySelector("#plain").addEventListener("click", switchPage); document.querySelector("#animated").addEventListener("click", () => { if (!document.startViewTransition) return switchPage(); document.startViewTransition(switchPage);});Lines 1 to 4 hold the two pages as HTML text. switchPage on lines 7 to 10 flips current and puts the other page into #page with innerHTML (the Modifying the document lesson covers it). Line 12 runs it straight from the Switch button: the page changes at once.
Line 16 is the new part. Switch with a view transition passes the same switchPage to document.startViewTransition, and the old page fades into the new one. Line 15 checks that the browser has the API first, and if it doesn't, just switches. The styles.css tab makes the fade last 0.8 seconds instead of the browser's 0.25, so it's easy to see. Its last rule turns the animation off for people who ask their system for less motion; more on that below.
document.startViewTransition(switchPage) takes a picture of the page, runs switchPage, and fades from the old picture to the new page. Your update only changes the page; the animation is the browser's.Since React 19.3, React has a stable component for this, <ViewTransition>. Here is the same switch in React. Click both buttons again:
import { ViewTransition, startTransition, useState } from "react"; const pages = { teas: { title: "Teas", text: "Green tea, chai, mint tea." }, cart: { title: "Your cart", text: "2 items: chai and mint tea. Total: $7" },}; export default function App() { const [current, setCurrent] = useState("teas"); const next = current === "teas" ? "cart" : "teas"; const page = pages[current]; return ( <> <button onClick={() => setCurrent(next)}>Switch</button> <button onClick={() => startTransition(() => setCurrent(next))}> Switch with a view transition </button> <ViewTransition> <section className="page"> <h3>{page.title}</h3> <p>{page.text}</p> </section> </ViewTransition> </> );}Lines 19 to 24 wrap the page in <ViewTransition>, imported from react on line 1. Line 15's Switch sets the state directly, and the page changes at once. Line 16's button sets the same state inside startTransition, and the page fades. You never call startViewTransition yourself: React calls it when an update inside a startTransition changes something in a <ViewTransition>.
A shop changes its window display at night. Before the decorator moves anything, she hangs a big photo of the old window in front of the glass. She rearranges everything behind it. Then the photo slowly fades, and people outside see the old window melt into the new one. They never see the boxes and ladders in between.
- In real life: A decorator takes a photo of the room before moving anything
- In React: The browser takes a picture of the page before your update
- In real life: The furniture is moved while the photo is up
- In React: Your update changes the real page
- In real life: Visitors see the photo melt into the new room
- In React: The old picture fades out over the new page
- In real life: The photo is taken down when the new room shows
- In React: The pictures are removed when the animation ends
A view transition animates the page from how it looked to how it looks after an update. The browser takes a picture of the page, runs your update, and animates from the old picture to the new page. In React, wrap part of the page in <ViewTransition>, and updates in a transition animate it.
The browser's View Transition API
React's component is built on the browser's API, so it pays to see that first. document.startViewTransition takes one function, the update callback, which changes the page. It returns an object, a ViewTransition, with three promises for its steps. This card logs each step. Click Show details and read the console under the result:
const card = document.querySelector("#card");const button = document.querySelector("#toggle"); function update() { console.log("2. update runs. The old page is already a picture."); card.classList.toggle("open"); button.textContent = card.classList.contains("open") ? "Hide details" : "Show details";} button.addEventListener("click", async () => { if (!document.startViewTransition) return update(); const transition = document.startViewTransition(update); console.log("1. startViewTransition returned."); await transition.updateCallbackDone; console.log("3. updateCallbackDone: the page has changed."); await transition.ready; const animations = document.documentElement.getAnimations({ subtree: true }); const parts = new Set(animations.map((animation) => animation.effect.pseudoElement)); console.log("4. ready. Animating:", [...parts].join(", ")); await transition.finished; console.log("5. finished. The pictures are gone.");});update on lines 4 to 8 opens or closes the card and changes the button's text. Line 11 is the check from the overview: without the API, it just runs update. Line 12 starts the view transition. Then the code waits for each promise with await. The console shows five lines, in this order:
1. startViewTransition returned.Line 13 runs right after the call. The update hasn't run yet: the browser first takes a picture of the page as it is.2. update runs.A moment later the browser callsupdate(line 5), and the real page changes behind the picture.3. updateCallbackDone(line 15). The promise on line 14 resolves when the update has finished.4. ready. Animating: …(line 19). Thereadypromise on line 16 resolves when the animation is about to start. Lines 17 and 18 list every animation on the page, and their names, like::view-transition-old(tea-card), are the parts the next section explains.5. finished.(line 21) Thefinishedpromise on line 20 resolves when the animation has ended and the pictures are gone.
The update must change the page before it returns (or before the promise it returns resolves), because the browser takes the new picture right after it. The object also has skipTransition(), which jumps to the end with no animation; the update still runs.
Browsers added the API at different times. In the table, the middle column is the version that first had document.startViewTransition(update). The last column is the version that first took an object, like this, which can carry types (you'll meet them later):
document.startViewTransition({
update: switchPage,
types: ["forward"],
});How to read the table: each row is a browser, and each cell is the first version with that form of the call.
| Browser | startViewTransition(update) | startViewTransition({ update, types }) |
|---|---|---|
| Chrome and Edge | Version 111 | Version 125 |
| Safari, on Mac and iPhone | Version 18 | Version 18.2 |
| Firefox | Version 144 | Version 147 |
Every current version of the major browsers has both forms, and MDN marks view transitions in one page as Baseline, newly available since October 2025, when Firefox 144 shipped them. Older browsers are still out there, so keep the check on line 11: without the API the page simply changes with no animation, which is a fine fallback.
Pictures you can style with CSS
While a view transition runs, the browser puts its pictures in a tree of pseudo-elements on top of the page. A pseudo-element is a part of the page that CSS can style, but that isn't in your HTML, like ::before. The console line from step 4 named them. Each part of the page that animates on its own gets four:
::view-transition-group(name)moves and resizes from the old position and size to the new ones.::view-transition-image-pair(name)holds the two pictures together.::view-transition-old(name)is the picture from before the update. By default it fades out.::view-transition-new(name)is the page after the update, live. By default it fades in.
root.The name comes from the CSS property view-transition-name. The whole page is named root by default. In the step-by-step example, the styles.css tab gives the card its own name on line 2: view-transition-name: tea-card. That's why the card grows into its new size instead of fading with the rest of the page. Lines 14 to 16 of that tab style its group: the move and resize take one second. Press Edit & run, open styles.css, change line 2 to view-transition-name: none, and click again: the card no longer grows on its own, and only the whole page fades.
Two rules come with names. A name may be on the page only once at a time, or the browser skips the whole transition. And a name is how the browser pairs the old and new picture of the same thing, which is what makes an element seem to move.
The animation runs in about a quarter of a second, too fast to read. The playground runs a real view transition on this page and stops it at its first frame, so you can drag it from start to end and see what the browser draws:
const transition = document.startViewTransition(() => {
flushSync(() => setOpen(!open));
});
await transition.ready;
const animations = document.documentElement
.getAnimations({ subtree: true })
.filter((animation) => animation.effect.pseudoElement.includes("(tea-card)"));
for (const animation of animations) animation.pause();
// The slider: every animation jumps to the same point.
animation.currentTime = duration * (progress / 100);details hiddenAssam black tea
Press Show details. The browser takes a picture of the card, React changes it inside flushSync, and the animation pauses at its first frame.
At 0%, the old picture's opacity is 1 and the new picture's is 0. As you drag, one fades out while the other fades in, and the group's width grows from the small card to the big one. At 100%, only the new picture shows; let it finish, and the real card is back. The code beside it uses flushSync from react-dom inside the update, because the update must change the page before it returns, and a React set call alone would only ask for a render. That's fine for a demo, but React has a better way, next.
Some people turn on a system setting that asks for less motion, because animation can make them feel dizzy or sick. Neither the browser nor React turns view transitions off for them. Every example in this lesson ends with this rule, which turns the animations off when that setting is on, so the page simply changes:
/* No animation for people who ask their system for less motion. */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}Line 2 is the media query that matches when the setting is on. Lines 3 to 5 select every view transition group and picture, with * for any name, and line 6 removes their animations.
React's ViewTransition component
In React you don't call startViewTransition or write view-transition-name. You wrap part of the page in <ViewTransition>, and React does both when that part changes in a transition. It became stable in React 19.3, released in September 2026, after a year as an experimental API; every example here runs React 19.3.1. Here are a tea's details that come and go. Click Show details and Hide details, and watch the console:
import { ViewTransition, startTransition, useState } from "react";import "./watchTransitions.js"; function Details() { return ( <ViewTransition> <p className="card">Grown in Assam. Strong and malty. Best with milk.</p> </ViewTransition> );} export default function App() { const [open, setOpen] = useState(false); function toggle() { startTransition(() => setOpen(!open)); } return ( <> <h3>Assam black tea</h3> <button onClick={toggle}>{open ? "Hide details" : "Show details"}</button> {open && <Details />} </> );}Details on lines 4 to 10 returns its paragraph inside a <ViewTransition>. Line 16 flips open inside startTransition, and line 23 shows Details while open is true. The paragraph fades in and out, and the console prints View transition started. Types: none each time. That line comes from the watchTransitions.js tab, imported on line 2: it wraps document.startViewTransition to print a line whenever React calls it.
Only what's inside a <ViewTransition> animates. React turns off the browser's picture of the whole page, the part named root, so the heading and the button change at once and stay clickable while the paragraph fades.
Press Edit & run and change line 16 to setOpen(!open);, without startTransition. The paragraph now appears and goes at once, and the console stays quiet: React never called the API.
React picks one of four kinds of animation for each <ViewTransition>, by what happened to it in the transition. All four are a cross-fade unless you choose otherwise. How to read the table: the first column is the kind, the second when React uses it, and the third where you've seen it here.
| Kind | When it happens | In this lesson |
|---|---|---|
| enter | The <ViewTransition> is added to the page in a transition. | The details paragraph appears after Show details. |
| exit | The <ViewTransition> is removed from the page in a transition. | The details paragraph goes after Hide details. |
| update | Something React changes inside it, or its size or place, changes in a transition. | The page switch in the overview, and the cart count. |
| share | A named one is removed and another with the same name is added in the same transition. | The photo that flies from the list to the details page. |
An Activity counts too. When an <Activity> switches between visible and hidden in a transition, a <ViewTransition> at the top of it enters as it shows and exits as it hides, and the hidden part keeps its state.
Enter and exit have one rule. React only animates a <ViewTransition> that comes before any DOM element in the part of the page that was added or removed. If Details wrapped it in a <div>, like this, nothing would animate in or out, because the <div> is the first thing added:
function Details() {
return (
<div>
<ViewTransition>
<p className="card">Grown in Assam. Strong and malty. Best with milk.</p>
</ViewTransition>
</div>
);
}Line 3 is the <div> that comes first. React keeps this rule so that a small change deep inside a page doesn't make large parts of it animate by surprise. Exercise 2 lets you try it.
React's startViewTransition in ReactFiberConfigDOM.js calls ownerDocument.startViewTransition({ update, types }) with the object form, at lines 2186 to 2274, and cancelRootViewTransitionName is where it turns off the whole-page picture. If the call throws, React applies the update with no animation. The comment in its catch block explains why: React only supports the version of the API that takes an object. So in a browser with only the older form (the middle column of the support table), React's view transitions don't animate at all.
} catch (x) {
// We use the error as feature detection.
// The only thing that should throw is if startViewTransition is missing
// or if it doesn't accept the object form. Other errors are async.
// I.e. it's before the View Transitions v2 spec. We only support View
// Transitions v2 otherwise we fallback to not animating to ensure that
// we're not animating with the wrong animation mapped.Which updates animate
A plain set call is urgent: React shows it as soon as it can, and an animation would only get in the way. So React animates a <ViewTransition> only for updates that are allowed to take a moment. There are three:
- Updates in a transition: inside
startTransition, or thestartTransitionthatuseTransitiongives you (the Transitions lesson covers both). - A Suspense reveal: a Suspense boundary swapping its fallback for its content.
- The background render where a deferred value catches up.
<ViewTransition> only for updates that are allowed to wait: a transition, a Suspense boundary showing its content, or a useDeferredValue catching up. Urgent updates show at once, as before.This example has all three kinds of update next to one <ViewTransition>. Try each one, and watch the console:
import { ViewTransition, startTransition, useDeferredValue, useState } from "react";import "./watchTransitions.js"; export default function App() { const [count, setCount] = useState(1); const [search, setSearch] = useState(""); const deferredSearch = useDeferredValue(search); return ( <> <button onClick={() => setCount(count + 1)}>Add one now</button> <button onClick={() => startTransition(() => setCount(count + 1))}> Add one in a transition </button> <input placeholder="Search teas" value={search} onChange={(event) => setSearch(event.target.value)} /> <ViewTransition> <p className="card"> {count} in the cart. Searching for: {deferredSearch || "nothing"} </p> </ViewTransition> </> );}- Add one now (line 11) sets the count directly. The number changes at once, and the console stays quiet.
- Add one in a transition (line 12) sets it inside
startTransition. The paragraph cross-fades, and the console printsView transition started. Types: none. - Type in Search teas. Line 15 sets
searchurgently, so the box shows your key at once. Line 7 gives a deferred copy, and line 18 shows that copy inside the<ViewTransition>. When it catches up, a moment later, the paragraph animates and the console prints a line.
A Suspense reveal animates too. Here the reviews come from a pretend server in the data.js tab, which answers after 1.5 seconds. Press Run again on the result to watch it again:
import { Suspense, ViewTransition, use } from "react";import { fetchReviews } from "./data.js";import "./watchTransitions.js"; function Reviews() { const reviews = use(fetchReviews("assam")); return ( <ul className="card"> {reviews.map((review) => <li key={review}>{review}</li>)} </ul> );} export default function App() { return ( <> <h3>Assam black tea</h3> <ViewTransition> <Suspense fallback={<p className="card">Loading reviews…</p>}> <Reviews /> </Suspense> </ViewTransition> </> );}Line 6 reads the reviews with use, which suspends until they arrive. Lines 18 to 22 put the Suspense boundary inside a <ViewTransition>. First you see Loading reviews…. When the reviews arrive, React swaps the fallback for the list inside a view transition, so it fades instead of jumping, and the console prints a line. Because the <ViewTransition> wraps the whole boundary, React treats the swap as an update. A <ViewTransition> around the fallback and another around the content would make it an exit and an enter instead.
How to read the table: each row is an update next to a <ViewTransition>, whether React animates it, and why.
| The update | Animates? | Why |
|---|---|---|
setCount(count + 1) in a click | No. | It's urgent: React shows it at once, as it always has. |
startTransition(() => setCount(count + 1)) | Yes. | A transition may take a moment, so React has time to take the pictures and animate. |
A <Suspense> boundary showing its content | Yes. | React treats the reveal like a transition and animates from the fallback to the content. |
The render where useDeferredValue catches up | Yes. | That second render is a background render, like a transition. |
Typing in a box: setSearch(event.target.value) | No. | Typing must show at once. Only the deferred render after it animates. |
Your own animations: classes and types
A cross-fade suits most changes, but a carousel should slide: left when you go forward, right when you go back. React lets you name a CSS class for each kind of animation, with the props enter, exit, update, share, and default. React calls these View Transition Classes. And because both buttons make the same kind of change, you also tell React why the change happened, with addTransitionType, which became stable in React 19.3 together with <ViewTransition>. Press Next and Previous:
import { ViewTransition, addTransitionType, startTransition, useState } from "react";import "./watchTransitions.js"; const teas = ["Green tea", "Chai", "Mint tea", "Earl Grey"]; export default function App() { const [index, setIndex] = useState(0); function go(step, type) { startTransition(() => { addTransitionType(type); setIndex((index + step + teas.length) % teas.length); }); } return ( <> <button onClick={() => go(-1, "previous")}>Previous</button> <button onClick={() => go(1, "next")}>Next</button> <ViewTransition key={index} enter={{ next: "from-right", previous: "from-left" }} exit={{ next: "to-left", previous: "to-right" }} > <p className="slide">Tea of the day: {teas[index]}</p> </ViewTransition> </> );}go on lines 9 to 14 starts a transition. Line 11 calls addTransitionType(type) inside it, which labels the transition "next" or "previous"; the buttons on lines 18 and 19 pass the label. Line 21 gives the <ViewTransition> a key that changes with every slide, so React removes the old slide and adds a new one: an exit and an enter.
Lines 22 and 23 pick the classes. An object maps a type to a class: for a "next" transition the new slide gets from-right and the old one to-left, and for "previous" it's the other way round. The console prints the type each time, like View transition started. Types: previous.
The styles.css tab draws the slides. Lines 8 to 11 select the pictures by class, with a dot: ::view-transition-new(.from-right) is the new picture of any part that has the class from-right. Lines 13 to 16 are the keyframes: the old slide moves 60 pixels away and fades out, and the new one comes from the other side. Lines 19 to 25 are the reduced motion rule.
A few more things to know about classes:
- A plain string, like
enter="slide-up", is used for every transition."auto"is the browser's cross-fade, and"none"turns that kind off. - In an object, a
defaultkey is used when no type matches. Without one, a type you didn't list gets the cross-fade. default="none"turns off every kind you don't name, so<ViewTransition default="none" enter="auto">animates only when it's added.- React also passes the types to the browser, so CSS can read them with
:active-view-transition-type(next), and it has event props, likeonEnter, for animating from JavaScript withelement.animate().
A tea shop with animated pages
Here is everything together: a list of three teas and a details page. Each photo flies to the details page and back, the transitions carry a type for the direction, and adding to the cart stays instant. Click a photo, Add to cart, and Back:
import { ViewTransition, addTransitionType, startTransition, useState } from "react";import "./watchTransitions.js"; const teas = [ { id: "assam", name: "Assam", color: "", note: "Strong and malty." }, { id: "sencha", name: "Sencha", color: "green", note: "Fresh and grassy." }, { id: "mint", name: "Mint", color: "mint", note: "Cool and sweet." },]; function Photo({ tea, size }) { return ( <ViewTransition name={`photo-${tea.id}`}> <div className={`photo ${size} ${tea.color}`}>{tea.name}</div> </ViewTransition> );} export default function App() { const [openId, setOpenId] = useState(null); const [cart, setCart] = useState(0); const open = teas.find((tea) => tea.id === openId); function go(id, type) { startTransition(() => { addTransitionType(type); setOpenId(id); }); } return ( <> <p>Cart: {cart}</p> {open ? ( <article> <Photo tea={open} size="big" /> <p>{open.note}</p> <button onClick={() => setCart(cart + 1)}>Add to cart</button> <button onClick={() => go(null, "back")}>Back</button> </article> ) : ( <div className="grid"> {teas.map((tea) => ( <button key={tea.id} aria-label={tea.name} onClick={() => go(tea.id, "forward")}> <Photo tea={tea} size="small" /> </button> ))} </div> )} </> );}Photo on lines 10 to 16 wraps every photo in a <ViewTransition> named after its tea, on line 12: photo-assam, photo-sencha, photo-mint. The list on lines 41 to 47 uses it small, and the details page on line 35 big. go (lines 23 to 28) opens or closes a tea in a transition, labelled "forward" or "back" on lines 43 and 38, and the console prints the type each time.
Line 37's Add to cart is urgent on purpose: the count on line 32 changes at once, and the console stays quiet. A cart count should never wait for an animation. The types aren't used by any class yet; in exercise 5 you use them the way the carousel does.
onClick={() => setOpen(true)}onClick={() => startTransition(() => setOpen(true))}const [isPending, startTransition] = useTransition(); … startTransition(() => setTab("cart"))const deferredQuery = useDeferredValue(query);- A
<Suspense>boundary finishes loading and shows its content startTransition(() => { addTransitionType("next"); setIndex(index + 1); })- A
<div>with a<ViewTransition>inside it is added in a transition onChange={(event) => setQuery(event.target.value)}
Each card is an update to a page with a ViewTransition in it. Sort it: does React animate it, or does the change show at once?
Common mix-ups
- "A ViewTransition animates every change inside it." No. It animates only updates in a transition, a Suspense reveal, or a deferred value catching up. A plain set call shows at once.
- "I should call document.startViewTransition in my React code." No. React calls it for you, and a transition started by someone else on the page is interrupted by React's. Use
<ViewTransition>andstartTransition. - "The browser animates my real elements." No. It animates pictures of them, in pseudo-elements on top of the page. The real page changes at once, underneath.
- "Every ViewTransition needs a name." No. React makes unique names itself. Give a name only to pair two of them for a shared element, and keep each name unique on the page.
- "People who prefer less motion won't see the animation." Only if you add the
prefers-reduced-motionrule. Neither React nor the browser does it for you. - "Old browsers will break." No. Where the API is missing, React applies the update with no animation, and the page works as before.
Practice exercises
Each exercise shows its result under the code, or when you press Edit & run. Watch the console: a View transition started line means React animated. Try a guess first, then use the hints.
Does the paragraph animate when you click Add one? Answer first, then press Edit & run, click, and watch the console.
import { ViewTransition, useState } from "react";
import "./watchTransitions.js";
export default function App() {
const [count, setCount] = useState(1);
return (
<>
<button onClick={() => setCount(count + 1)}>Add one</button>
<ViewTransition>
<p className="card">{count} in the cart</p>
</ViewTransition>
</>
);
}import { ViewTransition, useState } from "react";import "./watchTransitions.js"; export default function App() { const [count, setCount] = useState(1); return ( <> <button onClick={() => setCount(count + 1)}>Add one</button> <ViewTransition> <p className="card">{count} in the cart</p> </ViewTransition> </> );}No. setCount(count + 1) on line 9 is a plain, urgent update, so React shows the new count at once and never calls startViewTransition. The console stays quiet. Wrap it in startTransition(() => …) and it cross-fades.
Show details uses startTransition, but the details never fade in and the console stays quiet. Fix Details. What does the console print when you press Show details?
import { ViewTransition, startTransition, useState } from "react";import "./watchTransitions.js"; function Details() { return ( <div> <ViewTransition> <p className="card">Grown in Assam. Strong and malty. Best with milk.</p> </ViewTransition> </div> );} export default function App() { const [open, setOpen] = useState(false); function toggle() { startTransition(() => setOpen(!open)); } return ( <> <h3>Assam black tea</h3> <button onClick={toggle}>{open ? "Hide details" : "Show details"}</button> {open && <Details />} </> );}import { ViewTransition, startTransition, useState } from "react";import "./watchTransitions.js"; function Details() { return ( <ViewTransition> <p className="card">Grown in Assam. Strong and malty. Best with milk.</p> </ViewTransition> );} export default function App() { const [open, setOpen] = useState(false); function toggle() { startTransition(() => setOpen(!open)); } return ( <> <h3>Assam black tea</h3> <button onClick={toggle}>{open ? "Hide details" : "Show details"}</button> {open && <Details />} </> );}With the <div> gone, the <ViewTransition> is the first thing in what's added, so React animates the enter and the exit. The console prints View transition started. Types: none on each click.
This plain JavaScript page switches a sale price at once. Make the switch fade with the browser's API, with a fallback. What does the price say after one click?
function switchSale() { const price = document.querySelector("#price"); price.textContent = price.textContent === "$4" ? "Sale: $3" : "$4";} document.querySelector("#sale").addEventListener("click", switchSale);function switchSale() { const price = document.querySelector("#price"); price.textContent = price.textContent === "$4" ? "Sale: $3" : "$4";} document.querySelector("#sale").addEventListener("click", () => { if (!document.startViewTransition) return switchSale(); document.startViewTransition(switchSale);});The listener now runs switchSale as the update callback, so the browser fades from the old price to the new one. Line 7 keeps the page working in a browser without the API. One click shows Sale: $3.
The details fade slowly, and nothing turns that off for people who ask for less motion. Add a rule to styles.css that does. Which media feature does your rule test?
import { ViewTransition, startTransition, useState } from "react";import "./watchTransitions.js"; function Details() { return ( <ViewTransition> <p className="card">Grown in Assam. Strong and malty. Best with milk.</p> </ViewTransition> );} export default function App() { const [open, setOpen] = useState(false); function toggle() { startTransition(() => setOpen(!open)); } return ( <> <h3>Assam black tea</h3> <button onClick={toggle}>{open ? "Hide details" : "Show details"}</button> {open && <Details />} </> );}import { ViewTransition, startTransition, useState } from "react";import "./watchTransitions.js"; function Details() { return ( <ViewTransition> <p className="card">Grown in Assam. Strong and malty. Best with milk.</p> </ViewTransition> );} export default function App() { const [open, setOpen] = useState(false); function toggle() { startTransition(() => setOpen(!open)); } return ( <> <h3>Assam black tea</h3> <button onClick={toggle}>{open ? "Hide details" : "Show details"}</button> {open && <Details />} </> );}The new rule at the end of styles.css matches only when the system asks for less motion, and removes the view transition animations, so the details just appear. Everyone else still sees the slow fade.
Both buttons slide the new tea in from the right, even Previous. Use transition types so Previous slides the other way. What does the console print when you press Previous?
import { ViewTransition, startTransition, useState } from "react";import "./watchTransitions.js"; const teas = ["Green tea", "Chai", "Mint tea", "Earl Grey"]; export default function App() { const [index, setIndex] = useState(0); function go(step) { startTransition(() => { setIndex((index + step + teas.length) % teas.length); }); } return ( <> <button onClick={() => go(-1)}>Previous</button> <button onClick={() => go(1)}>Next</button> <ViewTransition key={index} enter="from-right" exit="to-left"> <p className="slide">Tea of the day: {teas[index]}</p> </ViewTransition> </> );}import { ViewTransition, addTransitionType, startTransition, useState } from "react";import "./watchTransitions.js"; const teas = ["Green tea", "Chai", "Mint tea", "Earl Grey"]; export default function App() { const [index, setIndex] = useState(0); function go(step, type) { startTransition(() => { addTransitionType(type); setIndex((index + step + teas.length) % teas.length); }); } return ( <> <button onClick={() => go(-1, "previous")}>Previous</button> <button onClick={() => go(1, "next")}>Next</button> <ViewTransition key={index} enter={{ next: "from-right", previous: "from-left" }} exit={{ next: "to-left", previous: "to-right" }} > <p className="slide">Tea of the day: {teas[index]}</p> </ViewTransition> </> );}Each button now labels its transition, and the objects pick the classes by label, so Previous slides the other way. The console prints View transition started. Types: previous for Previous and Types: next for Next.
The photos don't fly to the details page, and the console shows errors. Fix the name on line 12. What name does Sencha's photo get?
import { ViewTransition, addTransitionType, startTransition, useState } from "react";import "./watchTransitions.js"; const teas = [ { id: "assam", name: "Assam", color: "", note: "Strong and malty." }, { id: "sencha", name: "Sencha", color: "green", note: "Fresh and grassy." }, { id: "mint", name: "Mint", color: "mint", note: "Cool and sweet." },]; function Photo({ tea, size }) { return ( <ViewTransition name="photo"> <div className={`photo ${size} ${tea.color}`}>{tea.name}</div> </ViewTransition> );} export default function App() { const [openId, setOpenId] = useState(null); const [cart, setCart] = useState(0); const open = teas.find((tea) => tea.id === openId); function go(id, type) { startTransition(() => { addTransitionType(type); setOpenId(id); }); } return ( <> <p>Cart: {cart}</p> {open ? ( <article> <Photo tea={open} size="big" /> <p>{open.note}</p> <button onClick={() => setCart(cart + 1)}>Add to cart</button> <button onClick={() => go(null, "back")}>Back</button> </article> ) : ( <div className="grid"> {teas.map((tea) => ( <button key={tea.id} aria-label={tea.name} onClick={() => go(tea.id, "forward")}> <Photo tea={tea} size="small" /> </button> ))} </div> )} </> );}import { ViewTransition, addTransitionType, startTransition, useState } from "react";import "./watchTransitions.js"; const teas = [ { id: "assam", name: "Assam", color: "", note: "Strong and malty." }, { id: "sencha", name: "Sencha", color: "green", note: "Fresh and grassy." }, { id: "mint", name: "Mint", color: "mint", note: "Cool and sweet." },]; function Photo({ tea, size }) { return ( <ViewTransition name={`photo-${tea.id}`}> <div className={`photo ${size} ${tea.color}`}>{tea.name}</div> </ViewTransition> );} export default function App() { const [openId, setOpenId] = useState(null); const [cart, setCart] = useState(0); const open = teas.find((tea) => tea.id === openId); function go(id, type) { startTransition(() => { addTransitionType(type); setOpenId(id); }); } return ( <> <p>Cart: {cart}</p> {open ? ( <article> <Photo tea={open} size="big" /> <p>{open.note}</p> <button onClick={() => setCart(cart + 1)}>Add to cart</button> <button onClick={() => go(null, "back")}>Back</button> </article> ) : ( <div className="grid"> {teas.map((tea) => ( <button key={tea.id} aria-label={tea.name} onClick={() => go(tea.id, "forward")}> <Photo tea={tea} size="small" /> </button> ))} </div> )} </> );}Every photo was named photo, so three were on the page at once and React logged an error. With the id in the name, each name is unique, and only the two photos of the same tea pair up. Sencha's photo is photo-sencha.
Check your understanding
For each React question, ask two things: is the update in a transition, a Suspense reveal, or a deferred value? And where does the <ViewTransition> sit?
Question 1 of 7What does
document.startViewTransition(update)do before it callsupdate?Choose an answer to see the explanation.
Question 2 of 7In the step-by-step card example, which line does the console print first after a click?
Read the code, then predictJavaScriptconst transition = document.startViewTransition(update); console.log("1. startViewTransition returned.");Choose an answer to see the explanation.
Question 3 of 7You click Add one in this component. Does the paragraph animate?
Read the code, then predictJSXfunction Cart() { const [count, setCount] = useState(1); return ( <> <button onClick={() => setCount(count + 1)}>Add one</button> <ViewTransition> <p>{count} in the cart</p> </ViewTransition> </> ); }Choose an answer to see the explanation.
Question 4 of 7
goBackruns. Which class does the entering slide get?Read the code, then predictJSXfunction goBack() { startTransition(() => { addTransitionType("previous"); setIndex(index - 1); }); } <ViewTransition key={index} enter={{ next: "from-right", previous: "from-left" }}> <p>{teas[index]}</p> </ViewTransition>Choose an answer to see the explanation.
Question 5 of 7What goes wrong with this list of photos?
Read the code, then predictJSX{teas.map((tea) => ( <ViewTransition key={tea.id} name="photo"> <img src={tea.photo} alt={tea.name} /> </ViewTransition> ))}Choose an answer to see the explanation.
Question 6 of 7Details wrapped in
<div><ViewTransition>…</ViewTransition></div>appear in a transition but don't animate. Why?Choose an answer to see the explanation.
Question 7 of 7How do you respect people who ask their system for less motion?
Choose an answer to see the explanation.
Key takeaways
document.startViewTransition(update)takes a picture of the page, runs your update, and animates from the old picture to the new page. Its promises,updateCallbackDone,ready, andfinished, follow the steps.- The animation is a tree of pseudo-elements: a group per name, with an old and a new picture.
view-transition-namenames a part, and CSS styles the pseudo-elements. - In React 19.3 and later, wrap part of the page in
<ViewTransition>. React animates it only for transitions, Suspense reveals, and deferred values catching up, and never for urgent updates. - React picks enter, exit, update, or share. Choose your own classes with props, pick them by cause with
addTransitionType, and pair two screens with a uniquename. - Always add a
prefers-reduced-motionrule, and keep a fallback for browsers without the API.
Remember the one-liner.
Mark the update as a transition, wrap what changes in a ViewTransition, and the browser animates from the old picture to the new page.
Coming next: When React re-renders. It opens the Performance stage: what makes a component render again, and when that costs something.