jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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ểuApp điển hìnhCách bật
Same-documentSPA, islands, in-page route swapdocument.startViewTransition(callback)
Cross-documentClassic 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-name trê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-transition tươ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)
  1. Chụp — Trình duyệt vẽ trang/DOM cũ lên layer.
  2. Cập nhật — Callback chạy hoặc document mới load.
  3. Chụp lại — Trạng thái visual mới được snapshot.
  4. Animate — Pseudo-element cross-fade hoặc keyframe của bạn.
  5. 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-elementVai trò
::view-transitionGố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.push trong startViewTransition; 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ăngChromiumFirefoxSafari
Same-document startViewTransitionYesYes (stable)Yes (recent)
Cross-document @view-transitionYesRolling / partialYes (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
  • 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-name riê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ệcAPI / CSS
SPA route changedocument.startViewTransition(() => { ... })
MPA link navigation@view-transition \{ navigation: auto; \}
Match thumbnail → heroSame view-transition-name on both elements
Style all gallery cardsview-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.