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.

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-documentSPA, islands, in-page route swapdocument.startViewTransition(callback)
Cross-documentClassic 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 / finished promises {Cập nhật async vẫn được qua promise ready / finished}.
  • Only one active view transition per document at a time {Chỉ một view transition active trên document}.
  • Pair with view-transition-name on elements you want to match across states {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 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-transition rules {Cả hai trang có rule @view-transition tươ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)
  1. Capture — Browser paints the old page (or old DOM) into layers {Chụp — Trình duyệt vẽ trang/DOM cũ lên layer}.
  2. Update — Your callback runs or the new document loads {Cập nhật — Callback chạy hoặc document mới load}.
  3. Capture again — New visual state is snapshotted {Chụp lại — Trạng thái visual mới được snapshot}.
  4. Animate — Pseudo-elements cross-fade or run your keyframes {Animate — Pseudo-element cross-fade hoặc keyframe của bạn}.
  5. 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-elementRole {Vai trò}
::view-transitionRoot 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 + flushSync for concurrent-safe DOM commits {React 19 ghi document.startViewTransition + flushSync để commit DOM an toàn với concurrent}.
  • Next.js App Router experiments and community patterns wrap router.push in startViewTransition; treat as progressive enhancement until your target browsers are universal {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>
  );
}

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}ChromiumFirefoxSafari
Same-document startViewTransitionYesYes (stable)Yes (recent)
Cross-document @view-transitionYesRolling / partialYes (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-name riê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 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

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}.