View Transitions API — Native-App-Smooth Page Transitions Without a Framework
Posted on: 4/20/2026 11:10:16 PM
Table of contents
- 1. Why do we need View Transitions?
- 2. Architecture and Lifecycle
- 3. Custom Animations — in practice
- 4. Impact on Core Web Vitals
- 5. Optimization strategies — eliminating the overhead
- 6. Integration with popular frameworks
- 7. Practical production patterns
- 8. Browser support and timeline
- 9. Conclusion
- 10. References
Building smooth page transitions on the web has always been hard. SPA frameworks like Vue Router or React Router mostly solved it by keeping the shell and swapping the content, but with a traditional MPA (Multi-Page Application) every navigation meant a blank screen and a full reload. The View Transitions API completely changes that: browsers natively support smooth transitions between two DOM states, works for both SPA and MPA, and doesn't depend on any framework.
1. Why do we need View Transitions?
1.1 The problem with traditional navigation
When a user clicks a link on an MPA, the browser goes through a sequential process:
sequenceDiagram
participant U as User
participant B as Browser
participant S as Server
U->>B: Click link
B->>B: Unload old page (blank screen)
B->>S: HTTP Request
S-->>B: HTML Response
B->>B: Parse HTML + CSS
B->>B: Layout + Paint
B->>U: Show new page
Note over U,B: 200-800ms of blank screen
feels "janky"
Traditional navigation — the blank gap between pages produces a broken experience
That blank gap is why users feel web apps are "slower" than native apps, even when actual load time is very fast. The issue isn't absolute speed — it's perceived performance.
1.2 Older solutions and their limits
| Solution | Pros | Cons |
|---|---|---|
| SPA framework (Vue/React Router) | Smooth transitions, keeps shell | Large bundle, SEO complexity, slow initial load |
| Manual CSS animation | Lightweight, full control | Complex state management, doesn't work cross-document |
| FLIP Animation | Great performance (compositor-only) | Complex code, manual position calculations |
| Barba.js / Swup | Easy MPA transitions | Extra dependency, accessibility issues, hard to maintain |
View Transitions API solves it cleanly
Rather than orchestrating transitions with JavaScript, the View Transitions API lets the browser snapshot the old state, perform the DOM update, and animate from the snapshot to the new state. The entire animation runs on the compositor thread — no main-thread blocking, no jank.
2. Architecture and Lifecycle
2.1 Same-Document Transitions (SPA)
The basic form — both states live in the same document:
graph LR
A["startViewTransition()"] --> B["Capture Phase
Snapshot old DOM"]
B --> C["Update Phase
Callback applies DOM changes"]
C --> D["Animation Phase
Animate old -> new"]
D --> E["Cleanup
Remove snapshots"]
style A fill:#e94560,stroke:#fff,color:#fff
style B fill:#2c3e50,stroke:#fff,color:#fff
style C fill:#2c3e50,stroke:#fff,color:#fff
style D fill:#e94560,stroke:#fff,color:#fff
style E fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
Same-document view-transition lifecycle
Minimal code to trigger a transition:
// Wrap DOM updates inside startViewTransition
document.startViewTransition(() => {
// Any DOM changes happen here
document.getElementById('content').innerHTML = newContent;
});
Just one wrapper line — the browser handles the rest: snapshot, animation, cleanup.
2.2 Cross-Document Transitions (MPA)
This is the real breakthrough. With an MPA, two entirely different pages (different HTML documents) can transition smoothly — and it needs zero JavaScript:
/* Add to the CSS of BOTH pages */
@view-transition {
navigation: auto;
}
When the browser sees both the outgoing and incoming pages opting in via @view-transition, it automatically:
- Snapshots the old page before unload
- Loads the new page
- Cross-fades between the snapshot and the new page
Important constraint
Cross-document transitions only work between two pages of the same origin (same-origin navigation). Cross-domain navigation will not trigger transitions. This is a security constraint — the browser cannot snapshot cross-origin content.
2.3 The pseudo-element tree
During a transition, the browser builds a pseudo-element tree overlaid on top of the document. Understanding this tree is the key to custom animations:
└─ ::view-transition-group(*) — container for each named element
└─ ::view-transition-image-pair(*) — holds the old/new pair
├─ ::view-transition-old(*) — frozen snapshot (static image of old page)
└─ ::view-transition-new(*) — live representation (the actual new page)
Each element with a view-transition-name creates its own group. Elements without a name share the default root group.
3. Custom Animations — in practice
3.1 Named Transitions — animate individual elements
Instead of cross-fading the whole page, you can name specific elements so the browser animates them independently:
/* Source page (list page) */
.article-card {
view-transition-name: hero-article;
}
/* Destination page (detail page) */
.article-header-image {
view-transition-name: hero-article;
}
The browser computes positions/sizes and animates .article-card → .article-header-image — an "expand" effect just like native apps. Same view-transition-name across two pages = the browser treats them as "the same element" to morph.
3.2 Custom keyframes
The default is a cross-fade, but you can completely override it:
/* Slide the old page left */
::view-transition-old(root) {
animation: slide-out 0.3s ease-in forwards;
}
/* Slide the new page in from the right */
::view-transition-new(root) {
animation: slide-in 0.3s ease-out forwards;
}
@keyframes slide-out {
to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slide-in {
from { transform: translateX(100%); opacity: 0; }
}
3.3 Transition Types — different animation per direction
In SPAs, you can assign a type to each transition to pick the right animation:
// Navigate forward -> slide right
document.startViewTransition({
update: () => navigateToDetail(id),
types: ['slide-forward']
});
// Navigate back -> slide left
document.startViewTransition({
update: () => navigateToList(),
types: ['slide-back']
});
/* CSS targets each type */
:active-view-transition-type(slide-forward) {
&::view-transition-old(root) {
animation: slide-out-left 0.3s ease-in;
}
&::view-transition-new(root) {
animation: slide-in-right 0.3s ease-out;
}
}
:active-view-transition-type(slide-back) {
&::view-transition-old(root) {
animation: slide-out-right 0.3s ease-in;
}
&::view-transition-new(root) {
animation: slide-in-left 0.3s ease-out;
}
}
3.4 Cross-document events — pageswap and pagereveal
For MPAs, two new events let you intervene in the transition between documents:
sequenceDiagram
participant Old as Old page
participant B as Browser
participant New as New page
Old->>B: Navigation triggered
B->>Old: Dispatch "pageswap" event
Note over Old: event.viewTransition
can skip() or
customize snapshots
B->>B: Snapshot old page
B->>B: Load new page
B->>New: Dispatch "pagereveal" event
Note over New: event.viewTransition
can customize
animation from incoming page
B->>B: Animate old -> new
B->>New: Transition complete
Cross-document transition lifecycle with pageswap/pagereveal
// On the destination page — customize based on incoming URL
window.addEventListener('pagereveal', (event) => {
if (!event.viewTransition) return;
const url = new URL(window.location);
// Use hero animation if we're navigating into a detail page
if (url.pathname.match(/\/article\/\d+/)) {
event.viewTransition.types.add('hero-expand');
}
});
4. Impact on Core Web Vitals
The View Transitions API is not a "free lunch" — there is a measurable cost on Core Web Vitals.
4.1 Real data from RUM (Real User Monitoring)
An A/B test across 5 websites and 500,000+ pageviews over 7 days reported:
| Condition | LCP increase (mobile) | LCP increase (desktop) |
|---|---|---|
| No throttling + warm network cache | ~70ms | ~5ms |
| 6x CPU slowdown | 4-29ms | - |
| 20x CPU slowdown (extreme) | 40-77ms | - |
What the 70ms number means
Adding 70ms to LCP on repeat mobile pageviews sounds small, but Google recommends LCP under 2.5 seconds. If your site is at 2.4s, +70ms can push you over the line. On the other hand, if LCP is at 1.5s, 70ms is well within budget — and you get significantly better perceived performance in exchange.
4.2 Why does LCP increase?
graph TD
A["Navigation starts"] --> B["Browser snapshots old page"]
B --> C["Load new page HTML/CSS"]
C --> D["Parse + Render new page"]
D --> E{"View Transition
enabled?"}
E -->|No| F["Paint immediately -> LCP recorded"]
E -->|Yes| G["Wait for snapshot ready"]
G --> H["Run cross-fade animation"]
H --> I["Paint final frame -> LCP recorded"]
style E fill:#ff9800,stroke:#fff,color:#fff
style G fill:#e94560,stroke:#fff,color:#fff
style F fill:#4CAF50,stroke:#fff,color:#fff
style I fill:#4CAF50,stroke:#fff,color:#fff
View Transition adds snapshot + animate steps before LCP is recorded
Essentially, the browser must wait for the snapshot and run the first animation frame before painting — so LCP is delayed by exactly the snapshot + first-frame time.
4.3 CLS and INP are not affected
Good news: View Transitions do not affect CLS (Cumulative Layout Shift) because animations run on the overlay pseudo-elements, not on the actual document. INP (Interaction to Next Paint) is also unaffected because transitions run on the compositor thread and don't block the main thread.
5. Optimization strategies — eliminating the overhead
5.1 Combine with Speculation Rules API
This is the strongest strategy: use the Speculation Rules API to prerender the destination page before the user clicks. When the transition fires, both pages are already rendered — the animation plays between two fully-rendered states with zero LCP penalty.
<script type="speculationrules">
{
"prerender": [
{
"where": {
"href_matches": "/*"
},
"eagerness": "moderate"
}
]
}
</script>
<style>
@view-transition {
navigation: auto;
}
</style>
Why this combo works
Speculation Rules prerenders the destination page → when the user clicks, the new page is already in memory → View Transitions only need to animate between two rendered states → no parse/render delay → LCP is unaffected. This is the pattern the Chrome team officially recommends.
5.2 Respect prefers-reduced-motion
Users can turn off animations via OS settings. Always wrap View Transitions in a media query:
@media (prefers-reduced-motion: no-preference) {
@view-transition {
navigation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
}
}
This way, users with reduced-motion enabled → no transition → no LCP penalty → both accessibility and performance are covered.
5.3 Only enable on larger screens
If mobile LCP is already near the 2.5s line, consider enabling View Transitions on desktop only (where the overhead is ~5ms):
@media (prefers-reduced-motion: no-preference) and (min-width: 768px) {
@view-transition {
navigation: auto;
}
}
5.4 Keep animations short
Longer animations = longer LCP delay. Recommendations:
| Transition type | Suggested duration | Why |
|---|---|---|
| Cross-fade (default) | 150-250ms | Noticeable but not a meaningful delay |
| Slide/Morph | 200-350ms | Needs more time for spatial movement |
| Hero expand | 300-500ms | Elements traveling long distances need more time |
6. Integration with popular frameworks
6.1 Vue Router
Vue Router doesn't integrate View Transitions natively, but you can combine them via a router guard:
// router/index.ts
router.beforeResolve((to, from) => {
// Only transition on actual navigation (not initial load)
if (from.name === undefined) return;
return new Promise((resolve) => {
document.startViewTransition(() => {
resolve();
// Vue Router will update DOM after resolve
});
});
});
<!-- Component to animate -->
<template>
<div class="product-card" :style="{ viewTransitionName: `product-${id}` }">
<img :src="image" />
<h3>{{ name }}</h3>
</div>
</template>
6.2 Nuxt
Nuxt 3 supports View Transitions via config:
// nuxt.config.ts
export default defineNuxtConfig({
app: {
viewTransition: true // Enable cross-document transitions
}
});
6.3 Astro
Astro was the first framework to integrate View Transitions natively with its own directive:
---
// layout.astro
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<ViewTransitions />
</head>
<body>
<img transition:name="hero" src={heroImage} />
<slot />
</body>
</html>
7. Practical production patterns
7.1 Progressive enhancement
Always check browser support before using the API:
// Feature detection
function navigateWithTransition(url, updateFn) {
if (!document.startViewTransition) {
// Fallback: navigate normally
updateFn();
return;
}
document.startViewTransition(() => updateFn());
}
7.2 Avoid view-transition-name conflicts
The most common mistake
Each view-transition-name must be unique in the document at the time of the transition. Two elements sharing a name will cause the transition to be skipped. Typical mistake: assigning the same name to every item in a list → only the last item animates.
/* WRONG — duplicate name */
.card { view-transition-name: card; }
/* CORRECT — unique name per id */
.card-1 { view-transition-name: card-1; }
.card-2 { view-transition-name: card-2; }
/* Or use inline style (Vue/React) */
/* :style="{ viewTransitionName: `card-${item.id}` }" */
7.3 Combine with skeleton loading
For pages with heavy content, combine skeletons + View Transitions for a smooth experience:
// Transition into the skeleton first
document.startViewTransition(async () => {
showSkeleton();
});
// When data is ready, transition from skeleton -> real content
const data = await fetchData();
document.startViewTransition(() => {
renderContent(data);
});
8. Browser support and timeline
9. Conclusion
The View Transitions API is a big step toward closing the gap between web apps and native apps. Its core strength is being a web standard — no dependencies, no bundle size, no framework lock-in. For MPAs, just two lines of CSS are enough for smooth transitions.
That said, the LCP impact must be considered, especially on mobile. The optimal strategy combines Speculation Rules API to prerender + prefers-reduced-motion to respect accessibility. This combo gives you both perceived performance and clean Core Web Vitals.
Where to start?
If your site is an MPA with mobile LCP under 2.0s: add @view-transition { navigation: auto; } to your global CSS, wrap it in a prefers-reduced-motion media query, and add Speculation Rules for internal links. Three steps, zero JavaScript, significantly upgraded UX.
10. References
- MDN — View Transition API
- Chrome Developers — Cross-document view transitions
- Chrome Developers — Smooth transitions with the View Transition API
- Core Web Vitals — The Impact of CSS View Transitions on Web Performance
- Vue Mastery — The Future of Vue: Vapor Mode (View Transitions context)
- DebugBear — View Transition API: Single Page Apps Without a Framework
DNS Deep Dive 2026 — Optimize Web Speed from the First Step with Anycast, Prefetch, and Cloudflare DNS
Vue 3 Performance 2026 - Optimizing rendering from component to bundle
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.