View Transitions vs. My Old CSS Habit: What Actually Changes

· 7 min read

I’ve made CSS transitions for years. This article shows you what changes when the browser does the coordinating instead. Also where we are with ViewTransition in ReactJs.

Almost every state change animation on the web runs on the same trick. Add a class, wait for the browser to notice, remove the class when the animations should end transitionend, or on simpler work that uses setTimeout set to whatever duration makes sense in the CSS. This is the pattern most front-end developers have leaned on for years, myself included, and it works well enough.

But the code always felt wrong to me. You end up doing most of the job by hand, coordinating a start state and an end state while the browser waits on 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

  1. The old way, and why it always felt fragile
  2. What the browser does differently
  3. React wraps it, with real constraints
  4. Building the same toggle two ways

The old way, and why it always felt fragile

Here’s the pattern we are going to work with:

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 new. The code before is not technically wrong it’s just you’re the one keeping three things in sync.

There’s the logical open flag. There’s a separate animating flag, we keep track on so the code knows when the transition is running. And there’s a CSS class name that has to match a selector we added in a separate file.

Miss the onTransitionEnd call, and the animation runs forever, breaking other interactions on the page. One bad pattern in some code I’ve seen is separate timing for CSS and one for JavacScript. Forget updating one and you get a bug that’s not easy find in reviews.

The bigger limitation is scope, though. CSS transitions animate properties changing on an element that already exists.

CSS transitions 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 solves 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 comes out boring. 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 target it. That’s changed quite a bit lately. 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, not included (yet) on React code. 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% support: 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 use it.

React wraps it, with real constraints

React’s <ViewTransition> is a declarative wrapper over that browser API. That is putting it simply but its true.

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. 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>
  );
}

There are three caveats you need to remember:

  1. <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.
  2. It only activates for state changes wrapped in startTransition(), useDeferredValue(), or a resolving <Suspense> boundary, so a plain setState call animates nothing at all.
  3. 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 crashes.

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 bit 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 direction flag through props yourself.

The 19.2 release notes are clear that work is still in progress. 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

You see the difference when you put both techniques side by side.

  • The CSS version uses class name for “open,” a second class name for “animating,” and an event handler to unwind that second one.
  • The View Transition version has 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>
  );
}

In the ViewTransition code above, the animating flag is gone. There’s nothing animation related to track, because the browser’s own transition object resolves when the animation finishes, and React never asks you to babysit it.

Just a gentle reminder about accessibility, we still need to set up an override for users who enabled “prefer reduced motion”. The browser does not include it when you enable view transitions:

@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. It was the same 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 a kind of contract to the browser: these two elements are the same thing, so animate between them accordingly.

Wrapping up

There’s nothing wrong about the old CSS pattern. For one element changing one or two properties, it’s still the least code one could write, and I’ll probably keep using that. See my codepen for samples.

It becomes more complex if the work involves more than one element, a full page swap, or shared indentity between what’s leaving the page and what’s arriving. That’s exactly the gap the View Transitions API is made for, 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. Can’t wait for a stable channel, until then, I’ll be watching the release notes.

Happy coding!

By @codespud

DISCLAIMER This is my personal weblog and learning tool. The content within it is exactly that – personal. The views and opinions expressed on the posts and the comments I make on this Blog represent my own and not those of people, institutions or organisations I am affiliated with unless stated explicitly. My Blog is not affiliated with, neither does it represent the views, position or attitudes of my employer, their clients, or any of their affiliated companies.

© 2006 - 2026, Copyright - codespud.com · RSS