View Transitions vs. My Old CSS Habit: What Actually Changes
· 7 min read
I’ve hand-rolled CSS transitions for years. Here’s what changes when the browser does the coordinating instead, and why the flashy new API still isn’t safe to ship.
Almost every toggle animation on the web runs on the same small trick. Add a class. Wait for the browser to notice. Remove the class on transitionend, or on a lazier day, on a setTimeout tuned to whatever duration got typed into the CSS. It’s the pattern most front-end developers have leaned on for years, myself included, and it works well enough because CSS transitions are forgiving about small timing mistakes.
There’s a nagging feeling that comes with it, though. You end up doing the browser’s job for it by hand, coordinating a start state and an end state while the browser just watches from the sidelines. So when <ViewTransition> kept showing up in React’s release notes, I wanted to actually sit down with it, instead of nodding along at another shiny component name.
Contents
- The old way, and why it always felt fragile
- What the browser does differently
- React wraps it, with real constraints
- Building the same toggle two ways
The old way, and why it always felt fragile
Here’s the pattern, stripped down to a card that expands:
function Card({ children }) {
const [open, setOpen] = useState(false);
const [animating, setAnimating] = useState(false);
function toggle() {
setAnimating(true);
setOpen((o) => !o);
}
return (
<div
className={`card ${open ? "card--open" : ""} ${animating ? "card--animating" : ""}`}
onTransitionEnd={() => setAnimating(false)}
onClick={toggle}
>
{children}
</div>
);
}.card {
max-height: 60px;
overflow: hidden;
transition: max-height 300ms ease;
}
.card--open {
max-height: 400px;
}Nothing here is technically wrong. It’s just that you’re the one keeping three things in sync by hand.
There’s the logical open flag. There’s a separate animating flag, tracked only so the code knows when the transition is running. And there’s a CSS class name that has to match a selector living in a different file entirely.
Miss the onTransitionEnd call, and the animating class sticks around forever, silently breaking the next click. Change the duration in CSS without updating a matching setTimeout somewhere else, and the two drift apart until a designer spots the jank in a demo. It’s an easy bug to ship and an easy one to miss in review, because both files look correct in isolation.
The bigger limitation is scope, though. CSS transitions animate a property changing on an element that already exists, full stop.
They can’t animate one element being swapped for a completely different one. They definitely can’t animate a whole page turning into a different page, because there’s no shared element left for the browser to transition between. For that gap, people reached for react-transition-group or framer-motion, which solve the mounting problem well but add a dependency and a second API on top of the CSS you already know.
What the browser does differently
The View Transitions API skips the class-juggling by working a level up, at the whole-DOM-snapshot level instead of the single-property level. You hand the browser a callback that updates the DOM, and it takes care of the rest:
document.startViewTransition(() => {
updateTheDOMSomehow();
});Before that callback runs, the browser takes a screenshot of the current page. After it runs, it takes a second screenshot of the new state. Then it builds a small pseudo-element tree, ::view-transition-old() and ::view-transition-new(), and cross-fades between the two images by default, since it already has both snapshots sitting right there.
No transitionend listener required. The browser owns the whole lifecycle, and it cleans up the pseudo-elements the moment it’s done.
sequenceDiagram
participant JS as Your callback
participant Browser
Browser->>Browser: snapshot "old" state
JS->>Browser: mutate the DOM
Browser->>Browser: snapshot "new" state
Browser->>Browser: build ::view-transition-old/new
Browser->>Browser: cross-fade (or your CSS animation)You can still write real CSS if the default cross-fade feels flat. The pseudo-elements are just elements, so you target ::view-transition-old(root) and ::view-transition-new(root) with your own @keyframes, and the browser runs those instead of its default fade.
What you’re not doing anymore is manually tracking which state you’re leaving and which state you’re entering, since the browser already has both.
This used to be a Chrome-only party trick, which is probably why a lot of older tutorials still hedge on it. That’s changed. Current support data puts single-document View Transitions at roughly 90% global usage, with Safari on board since version 18 and Firefox shipping it too.
There’s a second half to the platform API, separate from anything React wraps. Cross-document transitions animate across a full page navigation rather than inside a single-page app. Opting in takes one line of CSS, an @view-transition at-rule with navigation: auto, declared on both the page you’re leaving and the page you’re arriving at.
Two events, pagereveal and pageswap, fire on the way in and the way out, so the transition can be adjusted based on which page is which. Support here is a step behind the single-document API, though. Caniuse puts cross-document transitions at roughly 86% global usage: Chrome, Edge, and Safari 18.2+ all ship it, but Firefox still only has it partially implemented. For a blog like this one, still generated the old-fashioned multi-page way for most routes, that’s the half of the API that matters most, and the one to feature-detect before you lean on it.
React wraps it, with real constraints
React’s <ViewTransition> is a declarative wrapper over that same browser API. It’s fair to be upfront about where it stands.
As of the stable React 19.2 release in October 2025, <ViewTransition> did not ship. The component’s own reference page still lists it as Canary and Experimental channel only, months later, as of this writing. Plenty of tutorials online will show it to you like it’s a settled API. It isn’t yet, so treat any production use of it as an experiment you’re opting into on purpose, not a feature you can quietly reach for.
That said, the design is worth understanding regardless of channel, because it answers a real gap. The raw browser API is imperative, since you call startViewTransition() yourself and hand it a DOM-mutation callback. React is declarative, so React’s version flips that around: you wrap the JSX you want animated, and React decides, during a transition, which wrapped boundaries actually need one.
import { ViewTransition, startTransition } from "react";
function Card({ open, children }) {
return (
<ViewTransition>
<div className={open ? "card--open" : "card"}>{children}</div>
</ViewTransition>
);
}Three things about that snippet trip people up the first time.
<ViewTransition> has to be the outermost thing a component returns, not wrapped by a <div> or any other DOM element above it, or React can’t attach the enter and exit animations correctly. It only activates for state changes wrapped in startTransition(), useDeferredValue(), or a resolving <Suspense> boundary, so a plain setState call quietly animates nothing at all. And if two <ViewTransition> elements share the same name, expecting a shared-element morph between them, only one may be mounted at a time or React throws.
That last constraint makes sense once you remember it’s tracking identity across a delete-and-insert, not just a style change. React also gives you a way to vary the animation by why a transition happened, not merely that one happened, through addTransitionType:
<ViewTransition
enter={{
"navigation-forward": "slide-left",
"navigation-back": "slide-right",
default: "auto",
}}
>
<Page />
</ViewTransition>startTransition(() => {
addTransitionType("navigation-forward");
navigate("/next");
});That’s the piece the raw browser API doesn’t hand you for free: a forward navigation and a back navigation can now animate in opposite directions, from a single component, without you threading a “which way” flag through props yourself.
The 19.2 release notes are honest about the groundwork still underway. One change batches Suspense boundaries during server-side rendering specifically so content streaming in close together reveals as one animation instead of several chained ones. It’s framed plainly as prep work, not a finished feature, which matches everything else about this component’s status right now.
Building the same toggle two ways
Put the two approaches side by side on the exact same card, and the difference gets concrete fast. The CSS version needed a class name for “open,” a second class name for “animating,” and an event handler to unwind that second one. The View Transition version needs one wrapper and a trigger.
function Card({ children }) {
const [open, setOpen] = useState(false);
function toggle() {
startTransition(() => setOpen((o) => !o));
}
return (
<ViewTransition>
<div className={open ? "card--open" : "card"} onClick={toggle}>
{children}
</div>
</ViewTransition>
);
}The animating flag is just gone. There’s nothing left to track by hand, because the browser’s own transition object resolves when the animation finishes, and React never asks you to babysit it.
Reduced motion still needs an explicit override, though. The browser’s default cross-fade doesn’t check that preference on its own:
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}What surprised me most, going through this, is how little of the hard part actually changed. Coordinating a shared-element morph between a list item and its detail view was always going to require matching identity across two separate renders. That was true with react-transition-group, and it’s still true here.
<ViewTransition name="..."> just names that identity explicitly, instead of you inferring it from a key prop and hoping the DOM diff cooperated. The name is essentially a promise to the browser: these two elements are the same thing, so animate between them accordingly.
Wrapping up
The old CSS pattern isn’t wrong. For one element changing one property, it’s still the least code you’ll write, and I’ll probably keep reaching for it on the simplest toggles.
Where it falls apart is anything involving more than one element, a full page swap, or a shared identity between what’s leaving and what’s arriving. That’s exactly the gap the View Transitions API closes, by working at the snapshot level instead of the property level.
React’s <ViewTransition> makes that native capability feel at home in a component tree, which is genuinely nice. It’s also fair to wait for a stable channel before betting a real product on it, and I’ll be watching the release notes the same way you probably will.
Happy coding!