Post 19
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.

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
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.
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}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.
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}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 otherReact's useViewTransition
React wraps the imperative API so a state update can drive a transition without you touching document.startViewTransition directly.
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.
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-nameso 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.
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.