5 min read

I Ditched My Animation Library for the View Transitions API

Last month my route changes looked like garbage. You'd click a nav link, the old view vanished, there'd be a half-second of empty white, and the new page snapped in. Nobody complained, but I could feel it every single time. So I did what you do — I reached for Framer Motion, wrapped every route in AnimatePresence, and wrote forty lines of exit and enter orchestration spread across three layout components.

Then a friend sent me a one-line diff. The View Transitions API landed in Firefox 144 late last year, which means it's finally in every major browser in 2026 — Chrome since 111, Safari since 18, Firefox since 144. That cross-fade I'd hand-rolled? The browser does it natively now. I deleted the library the same afternoon.

This is the second native web API in a month that ate a dependency for me. The first was the HTML dialog element killing my modal library. The platform's been quietly catching up, and I keep being the last to notice.

The SPA version is one function call

In a single-page app the whole API is document.startViewTransition(). You hand it a callback that updates the DOM, the browser snapshots the before and after, then cross-fades them. That's the entire pitch.

import { flushSync } from 'react-dom'

function navigateWithTransition(to) {
  if (!document.startViewTransition) {
    return navigate(to) // bail on old browsers, no harm done
  }
  document.startViewTransition(() => {
    flushSync(() => navigate(to))
  })
}

In vanilla JS the callback is a class toggle and you're done. In React there's a wrinkle, and it's the one that'll cost you an afternoon if you don't know it: state updates are async and batched, so the browser can snapshot before React commits the new route. You end up cross-fading the old view into... the old view. Wrapping the navigation in flushSync forces React to commit synchronously inside the callback, so the browser snapshots the real new state and the fade lands on the right thing.

One more footgun. If your update is async — a data fetch — the transition captures the loading skeleton, not the final content. You either fetch first and transition after, or accept that a 200ms skeleton under a fade is invisible to humans. I do the latter.

Shared elements are where it gets fun

A cross-fade is fine. A shared-element transition — where a thumbnail flies open into the full detail view — is what makes people assume you wrote custom animation code. You didn't. You give both elements the same name:

.card-image { view-transition-name: hero-img }
.detail-image { view-transition-name: hero-img }

Same name on the old view and the new one, and the browser morphs one into the other — position, size, the works. The catch: names have to be unique within a single snapshot. Slap view-transition-name on every item in a list and the browser refuses, logs a duplicate error, and skips the whole transition. Set it dynamically on the one element that's actually participating, and clear it when you're done.

Tuning the default

The default morph is good enough that I rarely touch it. When I do, the old and new snapshots are addressable as pseudo-elements:

::view-transition-old(hero-img) {
  animation-duration: 0.25s
}
::view-transition-new(hero-img) {
  animation-duration: 0.4s
}

I keep that snippet close — honestly I keep all my CSS platform-API snippets in one place in Snippet Ark so I'm not re-googling pseudo-element names every other week. They're verbose and they don't stick in my head.

Multi-page apps get the better deal

Here's the part I didn't expect to care about. If you're not running a SPA — just a normal multi-page site with real HTML documents — you get view transitions too, and they're easier. No JavaScript at all:

@view-transition {
  navigation: auto
}

Drop that in your stylesheet on both pages and every same-origin navigation cross-fades by itself. The browser handles the snapshot, the navigation, the back button, all of it. I put this into a small Markdown editor I maintain — moving between notes now glides instead of flashing white. Two lines of CSS, zero JS.

The catch: cross-document transitions are in Chrome and Edge 126+ and Safari 18.2+, but Firefox hasn't shipped them as of mid-2026. The same-document version — the startViewTransition call — works everywhere. So I use the SPA call unconditionally and treat the cross-document at-rule as progressive enhancement. Firefox users get an instant navigation, which is exactly what they had before.

One check before you ship

The API respects prefers-reduced-motion by default — a user with reduced motion on gets the DOM update with no animation. Good. But your callback still runs. So if you tucked expensive work in there counting on the animation to mask it, reduced-motion users get a freeze instead of a fade. Keep the callback cheap. I check for the feature, keep the callback fast, and let the browser handle the rest.

Three months in, I haven't opened Framer Motion once. The library was solving a problem the browser now owns.