View Transitions vs JS Libraries
Part of View Transitions API Implementation in Modern View Transitions & Scroll APIs.
The problem
Animated transitions between views used to demand a JavaScript library: something to freeze the outgoing DOM, tween it against the incoming DOM, and manage the teardown. The native View Transitions API now performs the snapshot-and-crossfade choreography in the browser, with no runtime bundle. The decision is no longer “which library” but “library or platform” — and the answer turns on bundle cost, where the animation runs, how it degrades, and how much bespoke control you genuinely need.
Root-cause analysis: two different execution models
A JavaScript page-transition library operates on the live DOM. It reads geometry (often forcing synchronous layout), applies inline styles or Web Animations, and schedules teardown on completion. Every frame it drives passes through the main thread at least at setup, and any library that animates top, left, width or height incurs layout on each tick — the same layout thrashing risk as any hand-written animation.
The View Transitions API operates on snapshots. When you call document.startViewTransition(updateCallback), the browser captures the old view as an image, runs your DOM update, captures the new view, then animates between the two using a tree of ::view-transition pseudo-elements. Because these pseudo-elements are just images being crossfaded and transformed, driving them with transform and opacity keeps the animation on the compositor thread. The DOM update itself is synchronous and unanimated; only the pseudo-element layer moves. This is the same compositor discipline described across scroll-driven animation patterns — the properties you animate decide the thread, not the API.
Decision matrix
| Dimension | View Transitions API | JS page-transition library |
|---|---|---|
| Bundle size | 0 bytes — ships with the browser | Adds parse/compile/execute cost to the critical path |
| Execution thread | Compositor for transform/opacity on the snapshot pseudo-elements |
Main thread at setup; per-frame main-thread work if it animates layout |
| Progressive enhancement | Feature-detect; unsupported browsers do an instant swap | Runs everywhere but only if the bundle has loaded and parsed |
| Shared-element morph | First-class via view-transition-name |
Manual FLIP measurement and tweening |
| Bespoke choreography | Limited to what pseudo-element CSS can express | Full timeline control, physics, per-element sequencing |
| Maintenance | Tracks the platform; no dependency upgrades | Dependency to patch, audit and keep current |
| Debuggability | Standard CSS/Animations panel | Library-specific internals |
| Best fit | Crossfades, shared-element morphs, route changes | Complex, non-standard motion the declarative API cannot express |
Read top to bottom: unless a row in the right-hand column names a capability you actually require — typically bespoke choreography or physics — the native API wins on cost and thread behaviour.
Production code
The snippet uses the native API with a clean progressive-enhancement fallback and a reduced-motion gate. Unsupported browsers get an instant, correct swap; supported browsers get a compositor-driven crossfade.
// Intent: animate a route change with the native API, degrade to an instant
// swap where unsupported, and honour reduced-motion preferences.
function navigate(updateDOM) {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Fallback path: no API support, or the user asked for reduced motion.
// Either way, apply the DOM change immediately with no animation.
if (!document.startViewTransition || reduce) {
updateDOM();
return;
}
// Enhanced path: the browser captures before/after snapshots and
// crossfades the ::view-transition pseudo-elements on the compositor.
document.startViewTransition(() => updateDOM());
}
/* Intent: drive the snapshot pseudo-elements with compositor-safe properties. */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 240ms;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
/* default UA keyframes animate opacity — compositor-safe */
}
/* A shared element morphs by name; transform stays on the compositor. */
.hero-image {
view-transition-name: hero;
}
/* Accessibility gate: no cross-view motion for reduced-motion users. */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
Rendering Impact:
opacity+transformon::view-transition-*pseudo-elements — composite only. The DOM update insidestartViewTransitionis a single synchronous, un-animated pass; no per-frame layout is scheduled.
Verification checklist
Constraints and trade-offs
- The native API’s declarative surface cannot express arbitrary timelines; genuinely bespoke choreography (staggered physics, interruptible drags) still needs JavaScript.
- Snapshots are raster images: very large or continuously updating regions can be memory-heavy to capture, so scope
view-transition-nameto the elements that must morph. - Cross-document transitions require same-origin navigation and the
@view-transitionopt-in; see implementing cross-document view transitions in SPAs. - A JS library remains defensible when you must support a browser matrix where the native API is unavailable and the animation is non-negotiable — but measure the bundle cost against an instant swap first.
- Animating layout properties on the pseudo-elements reintroduces main-thread work; keep to
transformandopacity.
Related
- View Transitions API Implementation — the parent guide to snapshots, named elements and the transition lifecycle
- Implementing Cross-Document View Transitions in SPAs — same-origin, multi-page transition setup
- Accessible Motion Architecture — reduced-motion strategy for page and route transitions