View Transitions API — Native Page Animations Every Dev Should Know (2026)
Bilingual 2026 guide: same-document SPA and cross-document MPA view transitions, pseudo-elements, shared elements, reduced motion, Astro/React integration, browser support, and fallbacks.
Vì sao chuyển trang vẫn giật
Người dùng nhận ra cách trang xuất hiện, không chỉ nội dung. Cắt cứng — nháy trắng, nhảy layout, reset scroll — cảm giác rẻ dù app nhanh. Nhiều năm ta vá bằng SPA và animation FLIP tự viết: JS nặng, timing dễ vỡ, và đụng nút Back.
View Transitions API đưa chuyển trang vào compositor của trình duyệt: chụp snapshot, cross-fade hoặc morph giữa trạng thái cũ/mới, chạy animation ngoài main thread khi có thể. Năm 2026 đây là kỹ năng nền cho frontend — không còn thí nghiệm Chrome lạ.
Đọc thêm về CSS thân compositor:
Hai kiểu: cùng document vs khác document
| Kiểu | App điển hình | Cách bật |
|---|---|---|
| Same-document | SPA, islands, in-page route swap | document.startViewTransition(callback) |
| Cross-document | Classic MPA, multi-page sites, static blogs | @view-transition { navigation: auto; } in CSS |
Cả hai dùng chung cây pseudo-element và model tuỳ biến CSS. Khác ở ai kích hoạt — JS cập nhật DOM, hay trình duyệt navigate sang document mới.
Chuyển cùng document (SPA)
Gọi startViewTransition với callback đổi DOM. Trình duyệt chụp snapshot cũ, chạy callback, chụp snapshot mới, rồi animate giữa hai bên.
function navigateTo(view: 'list' | 'detail') {
if (!document.startViewTransition) {
render(view);
return;
}
document.startViewTransition(() => {
render(view);
});
}
Quy tắc quan trọng:
- Callback nên cập nhật DOM đồng bộ khi có thể. Cập nhật async vẫn được qua promise
ready/finished. - Chỉ một view transition active trên document.
- Ghép với
view-transition-nametrên element cần khớp giữa hai trạng thái.
// React 19+ — wrap state updates that change the tree
import { flushSync } from 'react-dom';
function onRouteChange() {
if (!document.startViewTransition) {
setRoute(next);
return;
}
document.startViewTransition(() => {
flushSync(() => setRoute(next));
});
}
flushSync buộc React commit DOM trong callback để snapshot khớp.
Chuyển khác document (MPA)
Với load trang đầy đủ, bật chuyển tự động trong CSS:
@view-transition {
navigation: auto;
}
Đặt trong stylesheet global trên mọi trang cần animate. Khi user navigate cùng origin và cả hai document opt-in, trình duyệt chạy view transition xuyên document — không cần startViewTransition trong JS trang.
Yêu cầu năm 2026:
- Cùng origin
- Cả hai trang có rule
@view-transitiontương thích - User không tắt animation (xem accessibility)
MPA mạnh trên site tĩnh và content — blog, docs — khi bạn không muốn router 200KB chỉ để fade.
Snapshot → animate: chuyện gì xảy ra
OLD DOM state NEW DOM state
┌─────────────┐ ┌─────────────┐
│ <header> │ │ <header> │
│ <main> │ callback or │ <main> │
│ <footer> │ navigation │ <footer> │
└─────────────┘ └─────────────┘
│ │
▼ ▼
raster snapshot raster snapshot
│ │
└──────────┬─────────────────────┘
▼
::view-transition (root)
│
┌─────────┴─────────┐
│ per named group │ ← view-transition-name match
│ ::view-transition-group(name)
│ ├─ ::view-transition-old(name) (fade out / morph from)
│ └─ ::view-transition-new(name) (fade in / morph to)
└─────────────────────┘
│
▼
compositor plays CSS animations
(default ~300ms cross-fade on root)
- Chụp — Trình duyệt vẽ trang/DOM cũ lên layer.
- Cập nhật — Callback chạy hoặc document mới load.
- Chụp lại — Trạng thái visual mới được snapshot.
- Animate — Pseudo-element cross-fade hoặc keyframe của bạn.
- Dọn — Snapshot bỏ; DOM thật là thứ user tương tác.
Điểm hay là user thấy bóng animate trong khi DOM thật đã cập nhật bên dưới.
Mô hình pseudo-element
Trong transition, trình duyệt tạo cây tạm pseudo-element:
| Pseudo-element | Vai trò |
|---|---|
::view-transition | Gốc chứa mọi group |
::view-transition-group(name) | Định vị/cỡ cặp khớp |
::view-transition-old(name) | Snapshot đi ra |
::view-transition-new(name) | Snapshot vào |
Nội dung không đặt tên dùng group mặc định (docs thường gọi root):
/* Page-wide fade */
::view-transition-old(root) {
animation: fade-out 0.25s ease-out both;
}
::view-transition-new(root) {
animation: fade-in 0.35s ease-in both;
}
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
Mặc định là cross-fade trên snapshot gốc. Keyframe tuỳ chỉnh thay thế.
Khớp element: view-transition-name
Transition phần tử dùng chung (hero morph, thumbnail → detail) cần cùng view-transition-name trên element ra và vào:
.product-thumb {
view-transition-name: product-hero;
}
.product-detail-hero {
view-transition-name: product-hero;
}
/* Only one element per name per snapshot — duplicates are skipped */
<!-- List view -->
<article class="product-thumb">
<img src="/img/a.jpg" alt="Widget" />
<h2>Widget</h2>
</article>
<!-- Detail view (after navigation) -->
<header class="product-detail-hero">
<img src="/img/a.jpg" alt="Widget" />
<h1>Widget</h1>
</header>
Trình duyệt nội suy hình học giữa snapshot cũ/mới trong ::view-transition-group(product-hero). Thêm chuyển động trên layer old/new:
::view-transition-old(product-hero),
::view-transition-new(product-hero) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(product-hero) {
animation-name: hero-shrink;
}
::view-transition-new(product-hero) {
animation-name: hero-grow;
}
@keyframes hero-shrink {
to { opacity: 0.85; filter: brightness(0.9); }
}
@keyframes hero-grow {
from { opacity: 0; }
}
Cạm bẫy: view-transition-name tạo stacking context và tốn chi phí nếu lạm dụng — chỉ đặt tên hero, không phải mọi dòng list.
Nhóm với view-transition-class (2026)
Khi nhiều element cùng style trong transition, view-transition-class nhóm để nhắm CSS mà không cần tên riêng từng node:
.card {
view-transition-name: card;
view-transition-class: gallery-item;
}
::view-transition-group(.gallery-item) {
animation-duration: 0.35s;
}
::view-transition-old(.gallery-item) {
animation: slide-out 0.35s ease both;
}
::view-transition-new(.gallery-item) {
animation: slide-in 0.35s ease both;
}
Dùng class cho công thức chuyển động chung; dùng name cho liên tục một-một giữa hai element.
Ví dụ: item list morph thành hero detail
<!-- /products — list -->
<ul id="catalog">
<li>
<a href="/products/widget" style="view-transition-name: widget-hero">
<img src="/widget-thumb.jpg" alt="" />
<span>Widget Pro</span>
</a>
</li>
</ul>
<!-- /products/widget — detail -->
<main>
<figure style="view-transition-name: widget-hero">
<img src="/widget-hero.jpg" alt="Widget Pro" />
</figure>
<h1>Widget Pro</h1>
</main>
@view-transition {
navigation: auto;
}
::view-transition-group(widget-hero) {
overflow: clip;
}
::view-transition-old(widget-hero) {
object-fit: cover;
}
::view-transition-new(widget-hero) {
object-fit: cover;
}
Trên trình duyệt hỗ trợ, thumbnail trông như phóng to thành hero; không hỗ trợ thì navigate tức thì.
Accessibility: prefers-reduced-motion
Chuyển trang có animation có hại người nhạy cảm tiền đình. Luôn có nhánh reduced-motion:
@view-transition {
navigation: auto;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
/* Optional: disable cross-document transitions entirely */
@view-transition {
navigation: none;
}
}
Với SPA, bỏ qua startViewTransition khi user thích giảm chuyển động:
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
function updateView(next: View) {
if (prefersReducedMotion || !document.startViewTransition) {
render(next);
return;
}
document.startViewTransition(() => render(next));
}
Chú ý quản lý focus sau transition — đưa focus tới heading chính hoặc vùng route cho screen reader.
Tích hợp framework
Astro — <ClientRouter />
Astro 4+ có client router cho view transition trên MPA không cần React:
---
// src/layouts/BaseLayout.astro
import { ClientRouter } from 'astro:transitions';
---
<html lang="en">
<head>
<ClientRouter />
</head>
<body>
<slot />
</body>
</html>
Điều khiển từng link:
<a href="/about" data-astro-reload>Full reload (no transition)</a>
<a href="/blog" transition:animate="slide">Named animation</a>
Astro gắn plumbing cross-document khớp @view-transition và fallback navigate.
React / Next.js
- React 19 ghi
document.startViewTransition+flushSyncđể commit DOM an toàn với concurrent. - Next.js App Router có thử nghiệm bọc
router.pushtrongstartViewTransition; coi là progressive enhancement đến khi trình duyệt mục tiêu phủ đủ.
'use client';
import { useRouter } from 'next/navigation';
export function SoftLink({ href, children }: { href: string; children: React.ReactNode }) {
const router = useRouter();
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
if (!document.startViewTransition) {
router.push(href);
return;
}
document.startViewTransition(() => router.push(href));
}}
>
{children}
</a>
);
}
Mẫu progressive enhancement
export function withViewTransition(updateDom: () => void): void {
if (typeof document === 'undefined') return;
if (!document.startViewTransition) {
updateDom();
return;
}
document.startViewTransition(updateDom);
}
Ship nội dung trước, chuyển động sau. API transition không được chặn render hay navigate.
Hỗ trợ trình duyệt 2026 (thực tế)
| Khả năng | Chromium | Firefox | Safari |
|---|---|---|---|
Same-document startViewTransition | Yes | Yes (stable) | Yes (recent) |
Cross-document @view-transition | Yes | Rolling / partial | Yes (iOS/macOS recent) |
Baseline 2026: same-document an toàn cho phần lớn traffic nếu detect tính năng. Cross-document sẵn sàng progressive enhancement trên site content; test Safari + Firefox bạn nhắm.
Fallback êm:
const supportsViewTransitions =
typeof document !== 'undefined' && 'startViewTransition' in document;
if (!supportsViewTransitions) {
document.documentElement.classList.add('no-view-transitions');
}
.no-view-transitions * {
view-transition-name: none !important;
}
User trên trình duyệt không hỗ trợ navigate tức thì — vẫn đúng, vẫn nhanh.
Khi nào dùng / không dùng
Dùng khi:
- Muốn navigate cảm giác native không cần viết lại SPA
- Có phần tử dùng chung (ảnh, card, title) cần giữ hình ảnh qua route
- Tôn trọng reduced motion và giữ CLS thấp (snapshot đóng băng layout khi animate)
Bỏ qua hoặc hạn chế khi:
- Mọi dòng list có
view-transition-nameriêng (nổ GPU/bộ nhớ) - Trang khác hẳn — morph từ checkout sang blog không giá trị
- Cần điệu bộ chính xác đo JS mỗi frame — chỉ FLIP nhỏ trên hero
- Nội dung SEO bị ẩn đến khi transition xong (đừng chặn paint)
Bảng tra nhanh
| Việc | API / CSS |
|---|---|
| SPA route change | document.startViewTransition(() => { ... }) |
| MPA link navigation | @view-transition \{ navigation: auto; \} |
| Match thumbnail → hero | Same view-transition-name on both elements |
| Style all gallery cards | view-transition-class: gallery-item + ::view-transition-old(.gallery-item) |
| Fade entire page | ::view-transition-old(root), ::view-transition-new(root) |
| Disable for a11y | @media (prefers-reduced-motion: reduce) + skip JS API |
| Feature detect | 'startViewTransition' in document |
| Astro MPA | <ClientRouter /> from astro:transitions |
Gắn vào blog tĩnh
Setup tối thiểu cho Astro tĩnh như blog này:
/* global.css */
@view-transition {
navigation: auto;
}
::view-transition-old(root) {
animation: 0.2s ease-out both fade-out;
}
::view-transition-new(root) {
animation: 0.25s ease-in both fade-in;
}
@media (prefers-reduced-motion: reduce) {
@view-transition {
navigation: none;
}
}
---
import { ClientRouter } from 'astro:transitions';
---
<head>
<ClientRouter />
</head>
Thêm một view-transition-name trên title hoặc cover ở list + template bài khi muốn morph “đọc tiếp” kiểu editorial. Đo LCP và INP — transition không được trễ first paint trên landing.
Tóm tắt
View Transitions API là câu trả lời của platform cho navigate mượt: snapshot do compositor, pseudo-element điều khiển bằng CSS, cùng document cho SPA, khác document cho MPA. Coi là progressive enhancement, tôn trọng reduced motion, chỉ đặt tên element xứng đáng morph, trình duyệt không hỗ trợ load tức thì. Năm 2026, đó là tiêu chuẩn tối thiểu cho frontend chỉn chu.