View Transitions API — Chuyển Trang Mượt Như Native App Không Cần Framework
Posted on: 4/20/2026 11:10:16 PM
Table of contents
Từ trước đến nay, việc tạo hiệu ứng chuyển trang mượt mà trên web luôn là bài toán khó. Các SPA framework như Vue Router hay React Router giải quyết được phần nào bằng cách giữ nguyên shell và swap nội dung, nhưng với MPA (Multi-Page Application) truyền thống — mỗi lần navigate là một lần trắng màn hình, load lại từ đầu. View Transitions API thay đổi hoàn toàn câu chuyện này: browser native hỗ trợ chuyển cảnh mượt mà giữa hai trạng thái DOM, hoạt động với cả SPA lẫn MPA, không phụ thuộc bất kỳ framework nào.
1. Tại sao cần View Transitions?
1.1 Vấn đề với navigation truyền thống
Khi người dùng click một link trên MPA, trình duyệt phải trải qua chuỗi xử lý tuần tự:
sequenceDiagram
participant U as User
participant B as Browser
participant S as Server
U->>B: Click link
B->>B: Unload old page (trắng màn hình)
B->>S: HTTP Request
S-->>B: HTML Response
B->>B: Parse HTML + CSS
B->>B: Layout + Paint
B->>U: Hiển thị trang mới
Note over U,B: Khoảng trống trắng 200-800ms
gây cảm giác "giật"
Navigation truyền thống — khoảng trống trắng giữa hai trang tạo trải nghiệm không liền mạch
Khoảng trống trắng này là lý do người dùng cảm thấy web app "chậm" hơn native app, dù thời gian load thực tế có thể rất nhanh. Vấn đề không nằm ở tốc độ tuyệt đối mà ở perceived performance — cảm nhận về tốc độ.
1.2 Các giải pháp cũ và hạn chế
| Giải pháp | Ưu điểm | Hạn chế |
|---|---|---|
| SPA Framework (Vue/React Router) | Chuyển trang mượt, giữ shell | Bundle size lớn, SEO phức tạp, initial load chậm |
| CSS Animation thủ công | Nhẹ, kiểm soát tốt | Phải quản lý state phức tạp, không hoạt động cross-document |
| FLIP Animation | Performance tốt (compositor-only) | Code phức tạp, phải tính toán vị trí thủ công |
| Barba.js / Swup | MPA transitions dễ dùng | Dependency thêm, accessibility issues, khó maintain |
View Transitions API giải quyết gọn
Thay vì orchestrate transitions bằng JavaScript, View Transitions API để browser tự snapshot trạng thái cũ, thực hiện DOM update, rồi animate từ snapshot sang trạng thái mới. Toàn bộ animation chạy trên compositor thread — không block main thread, không jank.
2. Kiến trúc và Lifecycle
2.1 Same-Document Transitions (SPA)
Đây là dạng cơ bản nhất — cả hai trạng thái nằm trong cùng một document:
graph LR
A["startViewTransition()"] --> B["Capture Phase
Snapshot old DOM"]
B --> C["Update Phase
Callback thực thi 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
Lifecycle của same-document view transition
Code tối giản để kích hoạt transition:
// Wrap DOM update trong startViewTransition
document.startViewTransition(() => {
// Mọi thay đổi DOM xảy ra ở đây
document.getElementById('content').innerHTML = newContent;
});
Chỉ một dòng wrapper — browser tự lo phần còn lại: capture snapshot, chạy animation, cleanup.
2.2 Cross-Document Transitions (MPA)
Đây là tính năng đột phá thực sự. Với MPA, hai trang hoàn toàn khác nhau (khác HTML document) vẫn có thể transition mượt mà — và không cần một dòng JavaScript nào:
/* Chỉ cần thêm vào CSS của CẢ HAI trang */
@view-transition {
navigation: auto;
}
Khi browser nhận ra cả trang đi (outgoing) và trang đến (incoming) đều opt-in qua @view-transition, nó sẽ tự động:
- Snapshot trang cũ trước khi unload
- Load trang mới
- Animate cross-fade giữa snapshot cũ và trang mới
Ràng buộc quan trọng
Cross-document transitions chỉ hoạt động giữa hai trang cùng origin (same-origin navigation). Navigation sang domain khác sẽ không trigger transition. Đây là ràng buộc bảo mật — browser không thể snapshot nội dung cross-origin.
2.3 Cây Pseudo-Element
Khi transition diễn ra, browser tạo một cây pseudo-element overlay lên trên document. Hiểu cây này là chìa khóa để custom animation:
└─ ::view-transition-group(*) — container cho mỗi named element
└─ ::view-transition-image-pair(*) — chứa cặp old/new
├─ ::view-transition-old(*) — frozen snapshot (ảnh tĩnh trang cũ)
└─ ::view-transition-new(*) — live representation (trang mới thực)
Mỗi element có view-transition-name sẽ tạo một group riêng. Element không có name sẽ nằm chung trong group root mặc định.
3. Custom Animations — Thực chiến
3.1 Named Transitions — Animate từng element riêng
Thay vì cross-fade cả trang, bạn có thể đặt tên cho từng element để browser animate chúng độc lập:
/* Trang nguồn (list page) */
.article-card {
view-transition-name: hero-article;
}
/* Trang đích (detail page) */
.article-header-image {
view-transition-name: hero-article;
}
Browser sẽ tự tính toán vị trí, kích thước và animate .article-card → .article-header-image — tạo hiệu ứng "expand" giống native app. Cùng view-transition-name trên hai trang = browser hiểu đó là "cùng một element" cần morph.
3.2 Custom Keyframes
Mặc định browser dùng cross-fade, nhưng bạn có thể override hoàn toàn:
/* Slide trang cũ sang trái */
::view-transition-old(root) {
animation: slide-out 0.3s ease-in forwards;
}
/* Slide trang mới từ phải vào */
::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 — Animation khác nhau theo hướng navigate
Trong SPA, bạn có thể gán type cho transition để chọn animation tương ứng:
// Navigate forward → slide phải
document.startViewTransition({
update: () => navigateToDetail(id),
types: ['slide-forward']
});
// Navigate back → slide trái
document.startViewTransition({
update: () => navigateToList(),
types: ['slide-back']
});
/* CSS target từng 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 và pagereveal
Với MPA, hai sự kiện mới cho phép can thiệp vào transition giữa hai document:
sequenceDiagram
participant Old as Trang cũ
participant B as Browser
participant New as Trang mới
Old->>B: Navigation triggered
B->>Old: Dispatch "pageswap" event
Note over Old: event.viewTransition
có thể skip() hoặc
customize snapshots
B->>B: Snapshot old page
B->>B: Load new page
B->>New: Dispatch "pagereveal" event
Note over New: event.viewTransition
có thể customize
animation từ trang đến
B->>B: Animate old → new
B->>New: Transition hoàn tất
Lifecycle cross-document transitions với pageswap/pagereveal events
// Trên trang đích — customize transition dựa trên URL đến
window.addEventListener('pagereveal', (event) => {
if (!event.viewTransition) return;
const url = new URL(window.location);
// Nếu navigate vào trang chi tiết → dùng hero animation
if (url.pathname.match(/\/article\/\d+/)) {
event.viewTransition.types.add('hero-expand');
}
});
4. Tác động lên Core Web Vitals
View Transitions API không phải "free lunch" — nó có chi phí đo lường được trên các chỉ số Core Web Vitals.
4.1 Dữ liệu thực tế từ RUM (Real User Monitoring)
Một nghiên cứu A/B test trên 5 website, 500.000+ pageviews trong 7 ngày cho kết quả:
| Điều kiện | Tăng LCP (mobile) | Tăng LCP (desktop) |
|---|---|---|
| Không throttle + network cache | ~70ms | ~5ms |
| CPU slowdown 6x | 4-29ms | - |
| CPU slowdown 20x (extreme) | 40-77ms | - |
Giải thích con số 70ms
70ms thêm vào LCP trên repeat mobile pageviews có vẻ nhỏ, nhưng Google khuyến nghị LCP dưới 2.5 giây. Nếu site của bạn đang ở mức 2.4s, thêm 70ms có thể đẩy bạn qua ngưỡng. Ngược lại, nếu LCP đang ở 1.5s thì 70ms hoàn toàn chấp nhận được — đổi lại perceived performance tốt hơn đáng kể.
4.2 Tại sao LCP tăng?
graph TD
A["Navigation bắt đầu"] --> B["Browser snapshot old page"]
B --> C["Load new page HTML/CSS"]
C --> D["Parse + Render new page"]
D --> E{"View Transition
enabled?"}
E -->|Không| F["Paint ngay → LCP ghi nhận"]
E -->|Có| G["Chờ snapshot sẵn sàng"]
G --> H["Chạy cross-fade animation"]
H --> I["Paint final frame → LCP ghi nhận"]
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 thêm bước snapshot + animate trước khi LCP được ghi nhận
Bản chất: browser phải chờ snapshot sẵn sàng và chạy animation frame đầu tiên trước khi paint — nên LCP bị delay đúng bằng thời gian snapshot + first frame.
4.3 CLS và INP không bị ảnh hưởng
Tin tốt: View Transitions không ảnh hưởng CLS (Cumulative Layout Shift) vì animation chạy trên pseudo-element overlay, không trigger layout shift trên document thực. INP (Interaction to Next Paint) cũng không bị tác động vì transition chạy trên compositor thread, không block main thread.
5. Chiến lược tối ưu — Triệt tiêu overhead
5.1 Kết hợp Speculation Rules API
Đây là chiến lược mạnh nhất: dùng Speculation Rules API để prerender trang đích trước khi user click. Khi transition xảy ra, cả old page lẫn new page đều đã render sẵn — animation chạy giữa hai trạng thái hoàn chỉnh, LCP penalty = 0.
<script type="speculationrules">
{
"prerender": [
{
"where": {
"href_matches": "/*"
},
"eagerness": "moderate"
}
]
}
</script>
<style>
@view-transition {
navigation: auto;
}
</style>
Tại sao combo này hiệu quả?
Speculation Rules prerender trang đích → khi user click, trang mới đã sẵn sàng trong memory → View Transition chỉ cần animate giữa hai rendered states → không có delay parse/render → LCP không bị ảnh hưởng. Đây là pattern được Chrome team khuyến nghị chính thức.
5.2 Respect prefers-reduced-motion
Người dùng có thể tắt animation qua cài đặt hệ điều hành. Luôn wrap View Transitions trong media query:
@media (prefers-reduced-motion: no-preference) {
@view-transition {
navigation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
}
}
Với cách này, user bật reduced-motion → không có transition → không có LCP penalty → accessibility và performance đều được đảm bảo.
5.3 Chỉ bật trên màn hình lớn
Nếu mobile LCP đang gần ngưỡng 2.5s, có thể chỉ bật View Transitions trên desktop (nơi overhead chỉ ~5ms):
@media (prefers-reduced-motion: no-preference) and (min-width: 768px) {
@view-transition {
navigation: auto;
}
}
5.4 Giữ animation ngắn
Animation dài = LCP delay lâu. Khuyến nghị:
| Loại transition | Duration khuyến nghị | Lý do |
|---|---|---|
| Cross-fade (mặc định) | 150-250ms | Đủ nhận biết nhưng không delay đáng kể |
| Slide/Morph | 200-350ms | Cần thêm thời gian cho spatial movement |
| Hero expand | 300-500ms | Element di chuyển xa cần animate lâu hơn |
6. Tích hợp với các Framework phổ biến
6.1 Vue Router
Vue Router chưa tích hợp sẵn View Transitions API, nhưng có thể kết hợp qua router guard:
// router/index.ts
router.beforeResolve((to, from) => {
// Chỉ transition khi thực sự navigate (không phải initial load)
if (from.name === undefined) return;
return new Promise((resolve) => {
document.startViewTransition(() => {
resolve();
// Vue Router sẽ update DOM sau khi resolve
});
});
});
<!-- Component cần animate -->
<template>
<div class="product-card" :style="{ viewTransitionName: `product-${id}` }">
<img :src="image" />
<h3>{{ name }}</h3>
</div>
</template>
6.2 Nuxt
Nuxt 3 hỗ trợ View Transitions qua config:
// nuxt.config.ts
export default defineNuxtConfig({
app: {
viewTransition: true // Bật cross-document transitions
}
});
6.3 Astro
Astro là framework đầu tiên tích hợp View Transitions API native với directive riêng:
---
// layout.astro
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<ViewTransitions />
</head>
<body>
<img transition:name="hero" src={heroImage} />
<slot />
</body>
</html>
7. Pattern thực tế cho Production
7.1 Progressive Enhancement
Luôn kiểm tra browser support trước khi dùng API:
// Feature detection
function navigateWithTransition(url, updateFn) {
if (!document.startViewTransition) {
// Fallback: navigate bình thường
updateFn();
return;
}
document.startViewTransition(() => updateFn());
}
7.2 Tránh xung đột view-transition-name
Lỗi phổ biến nhất
Mỗi view-transition-name phải unique trong cùng một document tại thời điểm transition. Nếu hai element cùng name, transition sẽ bị skip. Sai lầm hay gặp: gán cùng name cho tất cả item trong một list → chỉ item cuối được animate.
/* SAI — duplicate name */
.card { view-transition-name: card; }
/* ĐÚNG — name unique theo id */
.card-1 { view-transition-name: card-1; }
.card-2 { view-transition-name: card-2; }
/* Hoặc dùng inline style (Vue/React) */
/* :style="{ viewTransitionName: `card-${item.id}` }" */
7.3 Kết hợp Skeleton Loading
Cho những trang có content nặng, kết hợp skeleton + View Transitions để tạo trải nghiệm liền mạch:
// Transition tới skeleton trước
document.startViewTransition(async () => {
showSkeleton();
});
// Khi data sẵn sàng, transition từ skeleton → real content
const data = await fetchData();
document.startViewTransition(() => {
renderContent(data);
});
8. Browser Support và Timeline
9. Kết luận
View Transitions API là bước tiến lớn trong việc thu hẹp khoảng cách trải nghiệm giữa web app và native app. Điểm mạnh cốt lõi nằm ở chỗ nó là web standard — không dependency, không bundle size, không lock-in vào framework nào. Với MPA, chỉ cần 2 dòng CSS là có transition mượt mà.
Tuy nhiên, cần cân nhắc kỹ tác động lên LCP, đặc biệt trên mobile. Chiến lược tối ưu là kết hợp Speculation Rules API để prerender + prefers-reduced-motion để tôn trọng accessibility. Với combo này, bạn được cả perceived performance lẫn Core Web Vitals sạch.
Bắt đầu từ đâu?
Nếu site của bạn là MPA với LCP mobile dưới 2.0s: thêm @view-transition { navigation: auto; } vào CSS global, wrap trong prefers-reduced-motion media query, và thêm Speculation Rules cho internal links. Ba bước, zero JavaScript, trải nghiệm nâng cấp đáng kể.
10. Tham khảo
- 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 — Tối ưu tốc độ web từ bước đầu tiên với Anycast, Prefetch và Cloudflare DNS
Vue 3 Performance 2026 - Tối ưu rendering từ component đến 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.