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

89%Browser coverage toàn cầu (Chrome, Edge, Firefox, Safari)
~70msTăng LCP trên mobile khi bật View Transitions
0 JSCross-document transitions không cần JavaScript
2-3xCảm giác nhanh hơn trên thiết bị low-end

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ểmHạn chế
SPA Framework (Vue/React Router)Chuyển trang mượt, giữ shellBundle size lớn, SEO phức tạp, initial load chậm
CSS Animation thủ côngNhẹ, kiểm soát tốtPhải quản lý state phức tạp, không hoạt động cross-document
FLIP AnimationPerformance tốt (compositor-only)Code phức tạp, phải tính toán vị trí thủ công
Barba.js / SwupMPA transitions dễ dùngDependency 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:

  1. Snapshot trang cũ trước khi unload
  2. Load trang mới
  3. 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 — root overlay, phủ toàn viewport
  └─ ::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ệnTăng LCP (mobile)Tăng LCP (desktop)
Không throttle + network cache~70ms~5ms
CPU slowdown 6x4-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àngchạ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 transitionDuration khuyến nghịLý do
Cross-fade (mặc định)150-250msĐủ nhận biết nhưng không delay đáng kể
Slide/Morph200-350msCần thêm thời gian cho spatial movement
Hero expand300-500msElement 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

Chrome 111 (Tháng 3/2023)
Same-document View Transitions (SPA) — API đầu tiên ship trong browser ổn định.
Chrome 126 (Tháng 6/2024)
Cross-document View Transitions (MPA) — tính năng đột phá, cho phép MPA transition không cần JS.
Safari 18 (Tháng 9/2024)
Apple bắt đầu hỗ trợ same-document transitions trong Safari và iOS WebKit.
Firefox 144 (Tháng 1/2026)
Firefox hoàn tất hỗ trợ same-document transitions — đạt Baseline Newly Available.
Tháng 4/2026 (Hiện tại)
Coverage toàn cầu đạt ~89%. Cross-document transitions hoạt động trên Chrome, Edge, Safari. Firefox đang triển khai.

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