Post 14

The View Transitions API for Real Apps

Native, GPU-accelerated transitions between DOM states and between pages — no animation library, no layout thrash. Here is how the API actually works, how to wire it into a Next.js app, and where it still bites.

Jul 12, 2026/10 min readFrontend
ShareY
The View Transitions API for Real Apps

The View Transitions API lets the browser animate between two states of the page — a filtered list, an expanded card, a whole new route — by taking a screenshot of the old state, a screenshot of the new state, and cross-fading (or morphing) between them on the compositor. No measuring, no FLIP calculations, no will-change guesswork. It has been in Chromium for a while and is landing across the other engines.

Same-document: one function

the whole API for in-page transitions
1function applyFilter(next: string) {2  if (!document.startViewTransition) {3    // no support — just do the update4    setFilter(next);5    return;6  }7 8  document.startViewTransition(() => {9    // whatever synchronous (or awaited) DOM update you want10    setFilter(next);11  });12}

The browser snapshots the page, runs your callback, snapshots again, and animates the difference. By default that is a cross-fade of the whole viewport. To animate specific elements independently, give them a view-transition-name.

name the elements that should move, not fade
1.card[data-active] {2  view-transition-name: active-card;3}4 5/* customise the animation for that named element */6::view-transition-group(active-card) {7  animation-duration: 280ms;8  animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);9}
Watch out

A view-transition-name must be unique on the page during the transition. Two elements with view-transition-name: card at the same time throws and aborts the transition. If you are mapping over a list, set the name only on the one item that is actually transitioning.

Cross-document: transitions between pages

For multi-page apps — including a Next.js app navigating between routes — you opt in with a CSS at-rule, and the browser transitions across the full navigation.

app/globals.css
1@view-transition {2  navigation: auto;3}4 5/* a shared element that should morph across the navigation */6::view-transition-old(hero),7::view-transition-new(hero) {8  animation-duration: 300ms;9}
the shared element on both routes
1// on /blog2<img src={post.cover} style={{ viewTransitionName: `cover-${post.slug}` }} />3 4// on /blog/[slug]5<img src={post.cover} style={{ viewTransitionName: `cover-${post.slug}` }} />6// same name on both pages → the browser morphs one into the other

React's useViewTransition

React wraps the imperative API so a state update can drive a transition without you touching document.startViewTransition directly.

components/tabs.tsx
1"use client";2import { unstable_ViewTransition as ViewTransition } from "react";3import { useState } from "react";4 5export function Tabs({ panels }: { panels: Panel[] }) {6  const [active, setActive] = useState(panels[0].id);7 8  return (9    <>10      <nav>11        {panels.map((p) => (12          <button key={p.id} onClick={() => setActive(p.id)}>{p.label}</button>13        ))}14      </nav>15      <ViewTransition>16        {/* content swap animates automatically */}17        <Panel key={active} data={panels.find((p) => p.id === active)!} />18      </ViewTransition>19    </>20  );21}

Respect reduced motion — properly

A cross-fade is motion. Users who ask for less of it should get a hard cut, not a slower fade.

app/globals.css
1@media (prefers-reduced-motion: reduce) {2  ::view-transition-group(*),3  ::view-transition-old(*),4  ::view-transition-new(*) {5    animation: none !important;6  }7}

Where it still bites

  • Snapshot cost. The browser rasterises the whole viewport twice. On a huge, complex page that is a visible hitch — keep transitions to sections, not the entire body, where you can.
  • Scroll position. Cross-document transitions and scroll restoration interact awkwardly; test back/forward navigation specifically.
  • `position: fixed` elements get captured in the snapshot and can appear to scroll away during the transition. Give them their own view-transition-name so they stay put.
  • No support = no transition. That is the correct fallback — the update still happens, just instantly. Never gate functionality on the API being present.
The value is not the fade. It is that the browser does the layout math for a shared-element morph that used to take a hundred lines of measuring code and still janked on Android.

Found this useful? Pass it on.

All posts

Want a custom write-up for your team? Get in touch.

Building something like this?

If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.