the idea: snapshot → mutate → crossfade
The View Transitions API (document.startViewTransition(cb))
screenshots the current page, runs your callback (where you change the
DOM), then crossfades the screenshot into the new live DOM. It is a
browser primitive for animated state changes
— no Framer Motion, no manual FLIP math. The default is a full-page
crossfade; tag an element with view-transition-name and the
browser morphs it between old and new boxes (a hero animation).
| approach | how it animates | cost |
|---|---|---|
| manual FLIP / JS lib | measure old rect → mutate → measure new → invert → play (you write all of it) | high — per-element bookkeeping, transforms, easing, cleanup |
CSS transition only |
animates properties you can name on elements that persist | cannot crossfade DOM that is replaced/removed |
startViewTransition |
browser snapshots whole tree; crossfades old↔new; morphs named elements | one JS call + a few CSS pseudo-element rules |
The transition is visual only — the DOM inside the callback updates for real (event listeners, layout, accessibility tree). The screenshot is a transient overlay layered on top while the animation plays.
1 · list ↔ detail crossfade
Click an item. The whole stage is wrapped in
startViewTransition(() => swap to detail). The browser
screenshots the list, swaps to the detail DOM, and crossfades
(::view-transition-old(root) → ::view-transition-new(root)).
Click back to reverse. The status line captures the
ViewTransition object the API returns, proving the machinery fired.
pick a release →
v4.0 — Oxide
Tailwind v4 rebuilt the engine in Rust.
last transition: (click an item)
2 · hero morph — a named element animates between layouts
The cyan chip below carries view-transition-name: hero. It
exists in BOTH the collapsed and expanded state, so when
startViewTransition runs the browser captures the old box
(small, top) and the new box (large, full-width) and morphs width, height,
and position smoothly on ::view-transition-group(hero). You
wrote zero transform math.
last hero transition: (click expand)
3 · feature detection + graceful fallback
Always feature-detect — Firefox ships this behind a flag. The idiom is a
one-line wrapper: if the API exists, wrap the mutation; otherwise just run
the mutation (instant swap, no animation). The status below is read live
from typeof document.startViewTransition.
| browser | same-document VT | note |
|---|---|---|
| Chrome / Edge / Opera | ✅ 111+ (2023) | Baseline Newly Available (2024) |
| Safari | ✅ 18+ (2024) | shipped Sept 2024 |
| Firefox | ⚠️ flag only | dom.viewTransitions.enabled — not on by default |
feature-detect: …
cheat sheet — pseudo-elements & lifecycle
| primitive | what it is | style it with |
|---|---|---|
::view-transition-group(name) |
wrapper that morphs position + size for a named element | animation-duration, animation-timing-function |
::view-transition-old(name) |
static screenshot of the element before the change | custom keyframes (fade/slide) — Tailwind arbitrary props work |
::view-transition-new(name) |
live capture of the element after the change | custom keyframes |
view-transition-name: x |
opt an element INTO a named morph (must be unique per document at transition time) | plain CSS, or Tailwind [view-transition-name:hero] arbitrary property |
::view-transition (no name) |
root overlay container holding the whole snapshot tree | rarely styled directly |
ViewTransition object lifecycle (the JS side):
.updateCallbackDone resolves when your callback's DOM mutation
commits · .ready resolves when pseudo-elements are built and
animations are about to start · .finished resolves when
animations complete (rejects if .skipTransition() is called).
Call transition.skipTransition() to bail out early.
Gold-check scans document.styleSheets for the
::view-transition-old/new(root) and
view-transition-name rules defined in this page's
<style> block — proving the transition CSS is loaded —
and feature-detects the JS API.