Guide
View Transitions API explained
Harbor Commerce runs a client-rendered product catalog with 1,800 SKUs.
Clicking a grid tile to open a detail page used to swap the entire viewport
in one frame — shoppers reported the jump felt “broken,” and
analytics showed a 14% back-out rate within two seconds of navigation.
The View Transitions API gives the browser a first-class way
to animate between DOM snapshots: capture the old state, apply your update,
capture the new state, and cross-fade or morph matching elements. Harbor
wired document.startViewTransition() around its router, tagged
product thumbnails and hero images with matching
view-transition-name values, and added a 280 ms root fade.
Perceived navigation friction dropped 52% in moderated usability tests;
LCP on detail
pages was unchanged because transitions run on the compositor after paint.
This guide covers same-document transitions in SPAs, cross-document
transitions for multi-page apps, shared-element morphing, CSS customization
via ::view-transition-* pseudo-elements, the Harbor Commerce
refactor, a decision table against animation libraries and scroll-driven
CSS, common pitfalls, and a production checklist.
What the API actually does
Traditional DOM updates are instantaneous from the user’s perspective: old nodes vanish, new nodes appear. Libraries like GSAP or Framer Motion animate properties manually, but coordinating whole-page or shared-element transitions across arbitrary layout changes is tedious. The View Transitions API delegates snapshotting and interpolation to the browser.
The flow for same-document transitions (typical SPA route change or filtered list update):
- Call
document.startViewTransition(updateCallback). - The browser captures a bitmap of the current page (the “old” snapshot).
- Your callback runs — mutate the DOM, swap routes, toggle themes.
- The browser captures the “new” snapshot and animates between
them, respecting
view-transition-namepairings for matched elements.
Elements without a transition name participate in the default root cross-fade. Named elements get their own layer: a thumbnail in a grid can morph into the hero image on a detail page if both share the same name during the transition window.
Cross-document view transitions extend the same machinery
to full page loads: the outgoing page opts in with a
@view-transition { navigation: auto; } rule (or a meta tag in
supporting browsers), and the incoming page participates when it paints.
This enables MPAs to feel app-like without a JavaScript framework.
JavaScript: startViewTransition and lifecycle hooks
The minimal SPA integration wraps your router’s DOM commit:
function navigate(url) {
if (!document.startViewTransition) {
renderRoute(url);
return;
}
document.startViewTransition(() => {
renderRoute(url);
});
}
startViewTransition returns a ViewTransition
object with promises you can await:
ready— pseudo-elements are created; safe to customize animations.finished— transition completed or was skipped.updateCallbackDone— your DOM callback finished (useful for data-fetch gating).
Pair transitions with async data by awaiting fetch inside the callback,
but keep the callback fast: long tasks block the old snapshot from
releasing. A common pattern fetches in parallel, starts the transition
when skeleton DOM is ready, then awaits updateCallbackDone
before swapping in rich content — or runs a second transition for
the content reveal.
Respect prefers-reduced-motion: skip or shorten transitions
when users request reduced motion. Feature-detect with
if (!document.startViewTransition) and fall back to instant
navigation — the API is supported in Chromium browsers and Safari
18+; Firefox support is evolving, so progressive enhancement is mandatory.
CSS: naming elements and styling pseudo-layers
Assign a unique view-transition-name to each element that
should morph independently:
.product-thumb {
view-transition-name: product-hero;
}
.detail-hero {
view-transition-name: product-hero;
}
Names must be unique per snapshot — only one element per
name on screen at capture time. For lists, generate names from IDs:
view-transition-name: product-${sku} on the clicked tile and
the detail hero.
Customize timing with pseudo-elements on the root group:
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 280ms;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
::view-transition-old(product-hero),
::view-transition-new(product-hero) {
animation-duration: 350ms;
mix-blend-mode: normal;
}
Other useful pseudo-elements include ::view-transition-group(name)
(positions the morphing box), ::view-transition-image-pair(name)
(clips old/new images), and per-name old/new layers.
Use animation: none on the root if you only want shared-element
motion without a full-page fade.
Cross-document transitions for multi-page apps
MPAs can opt in on both sides of a navigation. On the outgoing page:
@view-transition {
navigation: auto;
}
The browser preserves a live snapshot of the old page while the new document
loads. Matching view-transition-name values on the destination
page continue the morph. Requirements:
- Same-origin navigation (typically).
- Both documents participate — missing opt-in on either side falls back to a normal load.
- Names and element geometry should be stable enough to interpolate; wildly different aspect ratios need custom animation overrides.
Cross-document transitions pair well with
resource hints
(prefetch on hover) so the incoming page paints before the
morph window times out.
Harbor Commerce catalog refactor (worked example)
Harbor’s stack: Vite + vanilla router, product grid cards with
320×320 images, detail pages with a 640×640 hero. Before view
transitions, route changes replaced #app innerHTML in one
tick — no continuity cue.
Step 1: Router wrapper
Every internal link calls navigateWithTransition(href) which
prevents default, pushes history, and wraps render() in
startViewTransition. External links and form submits bypass
the wrapper.
Step 2: Dynamic transition names
On grid click, Harbor sets event.currentTarget.dataset.vtName =
'sku-' + sku and applies it as view-transition-name via
inline style. The detail template sets the hero’s name to the same
SKU string during the first paint inside the transition callback.
Step 3: Root fade tuning
Non-hero content uses the default root cross-fade at 280 ms. Title and price text fade without individual names — too many named elements increase memory on low-end phones. Harbor caps named elements at three per transition.
Step 4: Reduced motion and fallback
@media (prefers-reduced-motion: reduce) sets root animation
duration to 1 ms. Unsupported browsers call render()
directly; no polyfill attempted.
Results
Moderated study (n=24): median “navigation felt smooth” Likert score rose from 2.8 to 4.3. Back-out within 2 s fell from 14% to 6.8%. Lighthouse performance score unchanged; transitions are compositor-driven and do not block the main thread beyond the DOM swap itself.
Technique decision table
| Approach | Best for | Trade-offs |
|---|---|---|
| View Transitions API | SPA route changes, shared-element morphs, MPA continuity | Progressive enhancement; unique names per snapshot; limited Firefox |
| CSS / JS animation libraries | Micro-interactions, parallax, complex choreography | Manual snapshot pairing; no free cross-page morph |
| Intersection Observer + CSS | Scroll-triggered reveals, lazy load fades | Not tied to navigation; scroll-only |
| Full document reload | Simple static sites, maximum compatibility | Hard cuts; loses spatial context |
| RSC streaming + suspense | Fast first paint, server-fetched UI | Orthogonal to morph animation; combine both |
| Container queries | Layout reflow inside components | Layout only; no transition snapshots |
View transitions solve state-to-state continuity. Pair them with container queries for responsive cards and with code splitting so route chunks load before the morph completes on slow networks.
Common pitfalls
- Duplicate transition names: two elements with the same
view-transition-namein one snapshot invalidate the name. Audit with DevTools Rendering panel. - Long update callbacks: fetching and parsing inside the callback delays the new snapshot. Prefetch data or show skeleton UI first.
- Animating layout-heavy trees: morphing large subtrees is expensive. Name only the hero asset; let the root fade handle the rest.
- Fixed headers double-fading: position:fixed elements
may appear in both snapshots awkwardly. Exclude them with
view-transition-name: noneor a dedicated static name. - Accessibility neglect: always honor
prefers-reduced-motion; do not use transitions to hide slow loads. - Assuming universal support: feature-detect and test Safari vs Chromium timing differences.
- Clipping overflow: parent
overflow: hiddencan crop morph layers. Temporarily adjust overflow during transitions.
Practitioner checklist
- Feature-detect
document.startViewTransitionand provide instant fallback. - Wrap router commits, not individual style tweaks, to avoid transition spam.
- Generate unique
view-transition-namevalues per shared element. - Cap named elements per transition for mobile GPU budgets.
- Tune root vs named durations separately in
::view-transition-*rules. - Honor
prefers-reduced-motionwith near-zero durations. - Prefetch next-route assets on hover when using cross-document transitions.
- Verify LCP and INP are not regressed — transitions should not block paint.
- Test back-navigation: morph names must work in both directions.
- Document which routes participate so future pages opt in consistently.
Key takeaways
- The View Transitions API snapshots old and new DOM states and lets the browser interpolate between them.
view-transition-namepairs elements across snapshots for shared-element morphs.- Same-document transitions cover SPAs; cross-document rules extend continuity to MPAs.
- Progressive enhancement is required — unsupported browsers should navigate instantly without breakage.
- Combine with prefetch and code splitting so morphs mask load latency rather than exposing it.
Related reading
- CSS container queries explained — component-responsive layout without viewport breakpoints
- Intersection Observer API explained — scroll-triggered visibility without layout thrash
- Core Web Vitals explained — LCP, INP, and CLS budgets for production pages
- Code splitting explained — route chunks that load before transitions finish