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.
Why your navigations still feel janky {Vì sao chuyển trang vẫn giật}
Users notice how a page arrives, not just what it contains {Người dùng nhận ra cách trang xuất hiện, không chỉ nội dung}. A hard cut — white flash, layout jump, scroll reset — reads as cheap even when the app is fast {Cắt cứng — nháy trắng, nhảy layout, reset scroll — cảm giác rẻ dù app nhanh}. For years we fixed that with SPA frameworks and custom FLIP animations {Nhiều năm ta vá bằng SPA và animation FLIP tự viết}: heavy JS, fragile timing, and fights with the back button {JS nặng, timing dễ vỡ, và đụng nút Back}.
The View Transitions API moves page transitions into the browser compositor {View Transitions API đưa chuyển trang vào compositor của trình duyệt}: capture snapshots, cross-fade or morph between old and new states, and run animations off the main thread when possible {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ể}. In 2026 it is a baseline skill for frontend engineers — not a niche Chrome experiment {Năm 2026 đây là kỹ năng nền cho frontend — không còn thí nghiệm Chrome lạ}.
Related reading on compositor-friendly CSS {Đọc thêm về CSS thân compositor}: /blog/css-optimization-by-level/.
Two flavors: same-document vs cross-document {Hai kiểu: cùng document vs khác document}
| Flavor {Kiểu} | Typical app {App điển hình} | How you opt in {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 |
Both share the same pseudo-element tree and CSS customization model {Cả hai dùng chung cây pseudo-element và model tuỳ biến CSS}. The difference is who triggers the transition — your JS updating the DOM, or the browser navigating to a new document {Khác ở ai kích hoạt — JS cập nhật DOM, hay trình duyệt navigate sang document mới}.
Same-document transitions (SPA) {Chuyển cùng document (SPA)}
Call startViewTransition with a callback that mutates the DOM {Gọi startViewTransition với callback đổi DOM}. The browser snapshots the old state, runs your callback, snapshots the new state, then animates between them {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);
});
}
Rules that matter {Quy tắc quan trọng}:
- The callback should update the DOM synchronously where possible {Callback nên cập nhật DOM đồng bộ khi có thể}. Async updates are supported via the transition’s
ready/finishedpromises {Cập nhật async vẫn được qua promiseready/finished}. - Only one active view transition per document at a time {Chỉ một view transition active trên document}.
- Pair with
view-transition-nameon elements you want to match across states {Ghép vớiview-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 forces React to commit DOM updates inside the transition callback so snapshots align {flushSync buộc React commit DOM trong callback để snapshot khớp}.
Cross-document transitions (MPA) {Chuyển khác document (MPA)}
For full page loads (link clicks, form navigations), enable automatic cross-document transitions in CSS {Với load trang đầy đủ, bật chuyển tự động trong CSS}:
@view-transition {
navigation: auto;
}
Put this in a global stylesheet loaded on every page you want to animate {Đặt trong stylesheet global trên mọi trang cần animate}. When the user follows a same-origin navigation and both documents opt in, the browser runs a view transition across documents — no startViewTransition in page JS required {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}.
Requirements in 2026 {Yêu cầu năm 2026}:
- Same-origin navigation (scheme, host, port) {Cùng origin}
- Both pages include compatible
@view-transitionrules {Cả hai trang có rule@view-transitiontương thích} - User has not disabled animations (see accessibility below) {User không tắt animation (xem accessibility)}
MPA transitions shine on static sites and content sites — blogs, docs, marketing — where you refuse to ship a 200KB router just for a fade {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: what actually happens {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)
- Capture — Browser paints the old page (or old DOM) into layers {Chụp — Trình duyệt vẽ trang/DOM cũ lên layer}.
- Update — Your callback runs or the new document loads {Cập nhật — Callback chạy hoặc document mới load}.
- Capture again — New visual state is snapshotted {Chụp lại — Trạng thái visual mới được snapshot}.
- Animate — Pseudo-elements cross-fade or run your keyframes {Animate — Pseudo-element cross-fade hoặc keyframe của bạn}.
- Cleanup — Snapshots removed; live DOM is what users interact with {Dọn — Snapshot bỏ; DOM thật là thứ user tương tác}.
The magic is that users see animated ghosts while the real DOM is already updated underneath {Điểm hay là user thấy bóng animate trong khi DOM thật đã cập nhật bên dưới}.
The pseudo-element model {Mô hình pseudo-element}
During a transition, the browser exposes a temporary tree of pseudo-elements {Trong transition, trình duyệt tạo cây tạm pseudo-element}:
| Pseudo-element | Role {Vai trò} |
|---|---|
::view-transition | Root containing all groups {Gốc chứa mọi group} |
::view-transition-group(name) | Positions/sizes a matched pair {Định vị/cỡ cặp khớp} |
::view-transition-old(name) | Snapshot of the outgoing state {Snapshot đi ra} |
::view-transition-new(name) | Snapshot of the incoming state {Snapshot vào} |
Unnamed content uses the default group (often styled as root in docs) {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; }
}
Default behavior is a cross-fade on the root snapshot {Mặc định là cross-fade trên snapshot gốc}. Custom keyframes replace that {Keyframe tuỳ chỉnh thay thế}.
Matching elements: view-transition-name {Khớp element: view-transition-name}
Shared element transitions (hero morph, thumbnail → detail) require the same view-transition-name on the outgoing and incoming element {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>
The browser interpolates geometry between old and new snapshots in ::view-transition-group(product-hero) {Trình duyệt nội suy hình học giữa snapshot cũ/mới trong ::view-transition-group(product-hero)}. Add motion on the old/new layers {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; }
}
Gotcha {Cạm bẫy}: view-transition-name creates a stacking context and has a cost if overused — name only hero elements, not every list row {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}.
Grouping with view-transition-class (2026) {Nhóm với view-transition-class (2026)}
When many elements share styling during a transition, view-transition-class groups them for targeted CSS without unique names per node {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;
}
Use class for shared motion recipes; use name for one-to-one element continuity {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}.
Example: list item morphing into detail hero {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;
}
On supporting browsers, the thumbnail appears to grow into the hero; elsewhere the navigation is instant {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 {Accessibility: prefers-reduced-motion}
Animated route changes can harm vestibular-sensitive users {Chuyển trang có animation có hại người nhạy cảm tiền đình}. Always provide a reduced-motion path {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;
}
}
For SPA code paths, skip calling startViewTransition when the user prefers reduced motion {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));
}
Respect focus management after transitions — move focus to the main heading or route container for screen readers {Chú ý quản lý focus sau transition — đưa focus tới heading chính hoặc vùng route cho screen reader}.
Framework integration {Tích hợp framework}
Astro — <ClientRouter /> {Astro — <ClientRouter />}
Astro 4+ ships a client router for view transitions on multi-page apps without React {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>
Per-link control {Đ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 injects the plumbing for cross-document transitions aligned with @view-transition and fallback navigation {Astro gắn plumbing cross-document khớp @view-transition và fallback navigate}.
React / Next.js {React / Next.js}
- React 19 documents
document.startViewTransition+flushSyncfor concurrent-safe DOM commits {React 19 ghidocument.startViewTransition+flushSyncđể commit DOM an toàn với concurrent}. - Next.js App Router experiments and community patterns wrap
router.pushinstartViewTransition; treat as progressive enhancement until your target browsers are universal {Next.js App Router có thử nghiệm bọcrouter.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>
);
}
Progressive enhancement pattern {Mẫu progressive enhancement}
export function withViewTransition(updateDom: () => void): void {
if (typeof document === 'undefined') return;
if (!document.startViewTransition) {
updateDom();
return;
}
document.startViewTransition(updateDom);
}
Ship content first, motion second {Ship nội dung trước, chuyển động sau}. No transition API should block rendering or navigation {API transition không được chặn render hay navigate}.
Browser support in 2026 (reality check) {Hỗ trợ trình duyệt 2026 (thực tế)}
| Capability {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 in 2026: same-document is safe for most consumer traffic if you feature-detect {Baseline 2026: same-document an toàn cho phần lớn traffic nếu detect tính năng}. Cross-document is ready for progressive enhancement on content sites; test on Safari + Firefox release channels you care about {Cross-document sẵn sàng progressive enhancement trên site content; test Safari + Firefox bạn nhắm}.
Graceful fallback {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;
}
Users on unsupported browsers get instant navigation — still correct, still fast {User trên trình duyệt không hỗ trợ navigate tức thì — vẫn đúng, vẫn nhanh}.
When to use / when not to {Khi nào dùng / không dùng}
Use view transitions when {Dùng khi}:
- You want native-feeling navigations without a full SPA rewrite {Muốn navigate cảm giác native không cần viết lại SPA}
- A shared element (image, card, title) should persist visually across routes {Có phần tử dùng chung (ảnh, card, title) cần giữ hình ảnh qua route}
- You can honor reduced motion and keep CLS low (snapshots freeze layout during animation) {Tôn trọng reduced motion và giữ CLS thấp (snapshot đóng băng layout khi animate)}
Skip or limit when {Bỏ qua hoặc hạn chế khi}:
- Every list item has a unique
view-transition-name(GPU/memory blowup) {Mọi dòng list cóview-transition-nameriêng (nổ GPU/bộ nhớ)} - Pages differ radically — a morph from checkout to blog adds nothing {Trang khác hẳn — morph từ checkout sang blog không giá trị}
- You need precise choreography that depends on JS measurement every frame — reach for CSS + small FLIP only on the hero {Cần điệu bộ chính xác đo JS mỗi frame — chỉ FLIP nhỏ trên hero}
- SEO-critical content is hidden until transition ends (don’t block paint) {Nội dung SEO bị ẩn đến khi transition xong (đừng chặn paint)}
Quick reference cheatsheet {Bảng tra nhanh}
| Task {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 |
Putting it together on a static blog {Gắn vào blog tĩnh}
Minimal setup for an Astro static site like this one {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>
Add one view-transition-name on the post title or cover image in list + post templates when you want a editorial “continue reading” morph {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}. Measure LCP and INP — transitions should not delay first paint on landing {Đo LCP và INP — transition không được trễ first paint trên landing}.
Summary {Tóm tắt}
The View Transitions API is the platform answer to smooth navigations {View Transitions API là câu trả lời của platform cho navigate mượt}: compositor-driven snapshots, CSS-controlled pseudo-elements, same-document for SPAs, cross-document for MPAs {snapshot do compositor, pseudo-element điều khiển bằng CSS, cùng document cho SPA, khác document cho MPA}. Treat it as progressive enhancement, respect reduced motion, name only elements that earn a morph, and let unsupported browsers fall through to instant loads {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ì}. In 2026, that is table stakes for polished frontend work {Năm 2026, đó là tiêu chuẩn tối thiểu cho frontend chỉn chu}.