Quick Answer

The View Transitions API lets the browser animate between an old and a new DOM state by taking a snapshot of each one and running a customisable cross-fade (or any animation you write) between them — triggered with document.startViewTransition() for single-page apps, or a single CSS rule for full-page navigations. Same-document transitions work in every major browser as of 2026; cross-document (full page navigation) support is still catching up, especially in Firefox.

What Problem This Solves

Animating between two UI states — a list collapsing into a detail view, a thumbnail growing into a full image, a tab panel swapping content — used to mean one of two things: a manual FLIP technique (measure First, measure Last, Invert the difference with a transform, then Play the animation back), usually wrapped in a library, or committing to a framework-specific animation system built around the same underlying idea.

Both approaches exist because the browser already has both the old and new states in hand at the exact moment a DOM swap happens — a library doing FLIP is really just reconstructing, by measurement, information the rendering engine already had. The View Transitions API exposes that information directly instead of making every project re-derive it with getBoundingClientRect() calls and manual transform math.

The practical gap this closes shows up most clearly in two very common UI patterns that were disproportionately expensive to build well before this API existed. The first is the "detail view expands from a list item" pattern — tap a product thumbnail in a grid, and the product page's hero image appears to grow directly out of that exact thumbnail rather than just appearing. The second is simpler but just as fiddly to hand-roll: a tab panel or accordion section that cross-fades its content instead of popping between states instantly. Both used to require either a genuine animation library pulled in just for this, or enough custom measurement code that most teams simply shipped the instant, un-animated version instead and called it acceptable. The View Transitions API makes the animated version close to free for the common cases, which changes the calculus from "is this worth the engineering cost" to "is there a reason not to."

Same-Document (SPA) Transitions

The core API is one method, used to wrap whatever DOM mutation you want animated:

document.startViewTransition(() => {
  // the DOM change you want animated — can be sync or return a Promise
  updateDOM();
});

Here's the sequence that actually happens: the browser takes a screenshot-like snapshot of the current state, runs your callback to mutate the DOM (waiting on it if it returns a promise), takes a second snapshot of the resulting new state, and then automatically cross-fades between the two — with zero animation code required for that default case.

startViewTransition() returns a ViewTransition object with several promises for sequencing more complex logic: .ready resolves once the pseudo-element tree is set up and the animation is about to start, .updateCallbackDone resolves once your callback itself has finished, and .finished resolves once the whole animation has completed. The same object also exposes skipTransition(), which lets you cancel just the animation portion — the DOM update inside your callback still runs — useful if, say, the user navigates again before the first transition finishes.

One behaviour worth internalising before you reach for this in a real app: the browser takes the "old state" snapshot synchronously, the instant you call startViewTransition(), from whatever the DOM looks like right then. If your callback is asynchronous — say it awaits a fetch before updating the DOM — the old snapshot is still taken immediately, and only the "new state" snapshot waits for your callback's promise to resolve. That's usually exactly what you want (you're animating from "now" to "after the data arrives"), but it means a slow callback doesn't delay the capture of the starting point, only the capture of the destination, so a laggy transition shows the old content frozen in place rather than nothing at all while it waits.

Customizing the Animation: ::view-transition-old and ::view-transition-new

When a transition starts, the browser builds a temporary pseudo-element tree layered on top of the page: a root ::view-transition overlay, containing a ::view-transition-group for each transitioning element, each of which contains an ::view-transition-image-pair wrapping exactly two pseudo-elements — ::view-transition-old(name), a static snapshot of how that part of the page looked before, and ::view-transition-new(name), the incoming state.

The default animation is a plain cross-fade on the root, and you override it with ordinary CSS — no animation library, just @keyframes you'd write for anything else:

::view-transition-old(root) {
  animation: fade-out 0.3s ease-out;
}
::view-transition-new(root) {
  animation: fade-in 0.3s ease-in;
}

@keyframes fade-out { to { opacity: 0; } }
@keyframes fade-in { from { opacity: 0; } }

Because these are real, animatable pseudo-elements sitting in their own compositor layer, anything you can normally do with CSS animations — easing curves, different durations per element, transforms — works here exactly the same way.

There's a detail worth knowing before you start writing custom animations against these pseudo-elements: they don't exist in your regular DOM tree, so you can't select them with a normal descendant selector, and ordinary specificity rules for your page's other CSS don't reach into them either. They live in their own top-layer overlay, generated fresh for the duration of the transition and removed once it finishes, which is also why you can't just open DevTools' Elements panel and inspect them the way you would a normal node mid-transition without the transition actively running at that moment. If a custom ::view-transition-old(root) rule doesn't seem to be applying, the usual cause isn't a specificity fight with other CSS — it's a typo in the pseudo-element name itself, or targeting a named group that doesn't exist because the view-transition-name on the real element doesn't match what the rule expects.

Animating Individual Elements With view-transition-name

By default, the entire viewport is treated as a single transitioning unit under the name root. To animate one specific element independently — the classic "thumbnail grows into the full image" effect — opt it in with the view-transition-name CSS property:

.thumbnail {
  view-transition-name: hero-image;
}

Once that thumbnail has its own transition name, the browser captures it as its own ::view-transition-group(hero-image), separate from the root, and will automatically interpolate its position and size between the old layout and the new one — which is exactly how the "shared element transition" demos (the API's original, pre-standardisation name on Chrome) work: a list thumbnail and a detail-page hero image, given the same view-transition-name, appear to morph into each other across the navigation, with the browser handling the geometry.

One rule to hold onto here: every view-transition-name value must be unique among elements present in the DOM at the moment the transition's snapshot is taken. Give two simultaneously visible elements the same name and the transition doesn't partially work — it fails outright, as covered in the pitfalls below.

You don't have to name every element you want animated independently by hand, either — the value can come from a CSS custom property set per element, which matters a lot for dynamic content like a list of cards where you don't know the count ahead of time:

.product-card {
  view-transition-name: var(--card-transition-name);
}
<div class="product-card" style="--card-transition-name: product-42"></div>

Generating that value from a stable, unique identifier already in your data (a product ID, a record's primary key) is the safe pattern — never derive it from array index or position, since the same visual item swapping index between renders would then collide with a different item's old name, or silently stop matching the element it used to represent.

Cross-Document (Full Page Navigation) Transitions

For traditional multi-page sites — a real navigation from one HTML document to another, same-origin — there's a simpler, CSS-only opt-in. Add this rule to the CSS of both the departing and the arriving page:

@view-transition {
  navigation: auto;
}

With that in place, a same-origin navigation automatically gets the same snapshot-and-cross-fade treatment as the JavaScript API, without calling startViewTransition() yourself at all — the browser handles the whole sequence around the navigation itself.

Support here is narrower than same-document transitions: Chrome and Edge 126+ (June 2024), Safari 18.2+ (December 2024), and Firefox only partial as of version 144+. Treat it purely as an enhancement — a user on an unsupported browser just gets the ordinary, un-animated navigation they'd get anyway. Nothing breaks; they simply don't see the cross-fade.

There's a meaningful constraint worth knowing before reaching for this on a real multi-page site: both documents involved in the navigation need the @view-transition rule, and the navigation has to stay same-origin. A cross-document transition from your site to a different domain (even a subdomain, depending on your origin setup) won't animate, because the browser has no guarantee the destination document is even willing to participate, let alone that its layout maps sensibly onto the departing page's. This makes the feature a natural fit for a site like a blog or documentation set navigating between its own pages, and a poor fit for anything that routes through a third-party checkout or a different subdomain mid-flow.

Feature Detection: Make It an Enhancement, Not a Requirement

For the JavaScript API, feature-detect before calling it, and keep the un-animated path as the real fallback rather than an afterthought:

function navigateTo(update) {
  if (!document.startViewTransition) {
    update();
    return;
  }
  document.startViewTransition(update);
}

This pattern is why View Transitions can be adopted today with essentially zero downside risk: a browser without support runs update() directly, the DOM changes exactly as it would have anyway, and the only thing missing is the animation polish — never broken functionality.

Astro, this site's own framework, wraps the same browser API in a component for its own page-to-page navigation — currently named <ClientRouter /> (the older name, <ViewTransitions />, still works in Astro 5.x but is scheduled for removal in 6.0). Using it is as close to "two lines of code" as this gets: import it from astro:transitions and drop it in your layout's <head>.

The same feature-detection discipline matters for the CSS-only cross-document path, even though there's no explicit JavaScript branch to write for it. Because @view-transition { navigation: auto; } is just a CSS at-rule, an unsupported browser doesn't throw an error or log a warning about it at all — it simply ignores the rule it doesn't understand, the same way CSS has always silently dropped unrecognised properties. That's the right default behaviour here, but it does mean there's no feedback loop telling you a given browser skipped it; the only way to confirm the enhancement is actually active for a given visitor is to test directly in that browser, since the absence of an error tells you nothing about whether the animation ran.

Pitfalls to Know Before You Ship This

  • Duplicate view-transition-name values. If two elements present at the same moment share a name, the whole transition is skipped silently — no console error, the DOM update just happens without any animation. This bites dynamic lists hardest: a shared static name across every list item works until two items render simultaneously, at which point every transition on the page quietly stops animating. Generate a unique name per item (an ID-based name, not a shared class-level one).
  • Significant layout shifts look like a jump-cut. The default cross-fade works inside each element's own bounding box. If the old and new states have very different aspect ratios or positions without a matching view-transition-name tying them together, the result can look like an abrupt swap rather than a smooth morph — sometimes a custom animation on that specific pseudo-element is worth writing instead of relying on the default.
  • Respect prefers-reduced-motion. Wrap custom transition CSS in a @media (prefers-reduced-motion: no-preference) block rather than leaving it unconditional. The right behaviour for reduced-motion users usually isn't "no transition at all" but a near-instant cross-fade or opacity cut — the DOM update should still feel immediate, just without the motion.
  • It's for updates, not first paint. startViewTransition() animates a change to a page that already exists — it has nothing to animate on the very first load of a fresh page. For an entrance animation on first paint, that's a plain CSS animation, not this API.
  • Overlapping transitions can queue up awkwardly. Calling startViewTransition() again while a previous transition is still animating doesn't necessarily do what you'd expect by default — depending on timing, you can end up with transitions stacking or visually fighting each other. For any UI where rapid repeated triggers are realistic (fast clicking through a gallery, quick successive filter changes), check whether a previous transition is still active and either await its .finished promise or call skipTransition() on it before starting a new one, rather than assuming the browser will sort out the sequencing for you.

Frequently Asked Questions

Does the View Transitions API replace CSS transitions and animations?

No, it uses them. The API's job is building the old/new snapshot pseudo-elements and exposing them as real, styleable elements — you still animate them with ordinary CSS @keyframes and animation properties, the same as you would anything else.

Can I use it with Astro's own router?

Yes — Astro wraps the same browser API in a component, currently named <ClientRouter /> (imported from astro:transitions), which handles cross-document transitions for Astro's page-to-page navigation without you calling startViewTransition() by hand. The older name, <ViewTransitions />, still works in Astro 5.x but is being removed in 6.0.

What happens on a browser that doesn't support it?

With proper feature detection, nothing breaks — the DOM update or page navigation happens exactly as it would anyway, just without the animation. Treat it strictly as progressive enhancement.

Is this the same thing people used to call 'shared element transitions'?

Yes — that was the feature's working name during Chrome's original, pre-standardisation experiment, before it became the cross-browser View Transitions API.

Can a transition be cancelled once it's started?

The ViewTransition object returned by startViewTransition() exposes a skipTransition() method that skips the animation portion specifically. The DOM update inside your callback still completes — only the visual transition is skipped.

Why does my transition not animate at all even though the code looks right?

The most common cause is a duplicate view-transition-name on two elements present in the DOM at the same time — that fails the whole transition silently, with no animation and no console error, so check for name collisions first.