View Transitions API — Page transition mượt mà không cần framework

Bạn click một link, trang trắng xoá rồi nội dung mới xuất hiện. Trải nghiệm mặc định của web từ 30 năm nay. SPA giải quyết bằng client-side routing, nhưng đổi lại là bundle JS lớn và complexity cao. Multi-page app (MPA) thì nhẹ nhưng chuyển trang cứ giật giật.

View Transitions API là câu trả lời của browser: page transition mượt mà, hoạt động cả SPA lẫn MPA, animation chạy trên compositor thread — không block main thread.

Cơ chế hoạt động

Ý tưởng cốt lõi đơn giản: browser chụp snapshot trạng thái cũ, render trạng thái mới, rồi animate giữa 2 snapshot.

// Cách dùng cơ bản nhất — SPA
document.startViewTransition(() => {
  // Cập nhật DOM ở đây
  updateContent(newPageHTML);
});

Khi gọi startViewTransition():

  1. Browser capture screenshot của trang hiện tại (old state)
  2. Callback chạy — bạn cập nhật DOM
  3. Browser capture trạng thái mới (new state)
  4. Animate từ old → new bằng CSS animation trên pseudo-elements

Mặc định, animation là cross-fade. Nhưng bạn có thể customize hoàn toàn bằng CSS.

SPA: Tích hợp với client-side router

Vanilla JS

// router.js
async function navigate(url) {
  const response = await fetch(url);
  const html = await response.text();
  const parser = new DOMParser();
  const doc = parser.parseFromString(html, 'text/html');

  // Wrap DOM update trong view transition
  if (!document.startViewTransition) {
    // Fallback cho browser chưa support
    updateDOM(doc);
    return;
  }

  document.startViewTransition(() => {
    updateDOM(doc);
  });
}

function updateDOM(doc) {
  document.title = doc.title;
  document.querySelector('main').innerHTML =
    doc.querySelector('main').innerHTML;
}

React Router

import { useNavigate } from 'react-router-dom';
import { flushSync } from 'react-dom';

function useViewTransitionNavigate() {
  const navigate = useNavigate();

  return (to) => {
    if (!document.startViewTransition) {
      navigate(to);
      return;
    }

    document.startViewTransition(() => {
      // flushSync để DOM cập nhật đồng bộ trong callback
      flushSync(() => {
        navigate(to);
      });
    });
  };
}

// Sử dụng
function ProductCard({ product }) {
  const navigateWithTransition = useViewTransitionNavigate();

  return (
    <article onClick={() => navigateWithTransition(`/product/${product.id}`)}>
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
    </article>
  );
}

⚠️ flushSync là bắt buộc trong React. Không có nó, React sẽ batch state update và DOM chưa cập nhật khi startViewTransition capture new state.

MPA: Cross-document transitions

Đây là phần thú vị nhất. Từ Chrome 126+, View Transitions hoạt động giữa các trang khác nhau — không cần JavaScript router.

/* Khai báo trên CẢ HAI trang — trang cũ và trang mới */
@view-transition {
  navigation: auto;
}

Chỉ 3 dòng CSS. Browser tự handle snapshot và animation khi user navigate giữa các trang same-origin. Không cần JavaScript, không cần service worker, không cần framework.

Customize animation cho MPA

@view-transition {
  navigation: auto;
}

/* Cross-fade mặc định, nhưng chỉnh duration */
::view-transition-old(root) {
  animation-duration: 200ms;
}

::view-transition-new(root) {
  animation-duration: 200ms;
}

/* Slide animation cho page content */
::view-transition-old(page-content) {
  animation: slide-out 200ms ease-in;
}

::view-transition-new(page-content) {
  animation: slide-in 200ms ease-out;
}

@keyframes slide-out {
  to { transform: translateX(-100%); opacity: 0; }
}

@keyframes slide-in {
  from { transform: translateX(100%); opacity: 0; }
}

Named view transitions — animate từng element riêng

Đây là lúc View Transitions thực sự toả sáng. Bạn có thể gán view-transition-name cho element cụ thể, browser sẽ animate riêng element đó thay vì cross-fade cả trang.

Ví dụ: Product card → Product page

/* Trang listing: product card */
.product-card img {
  view-transition-name: product-hero;
}

.product-card h3 {
  view-transition-name: product-title;
}

/* Trang detail: product page */
.product-page .hero-image {
  view-transition-name: product-hero;
}

.product-page h1 {
  view-transition-name: product-title;
}

Khi navigate từ listing → detail, browser sẽ:

  1. Tìm element có cùng view-transition-name trên cả 2 trang
  2. Animate position + size từ vị trí cũ sang vị trí mới
  3. Cross-fade content nếu nội dung khác nhau

Kết quả: ảnh sản phẩm "bay" từ card lên hero image, title di chuyển mượt từ card lên heading. Giống native app, zero JavaScript.

⚠️ Mỗi view-transition-name phải unique trên trang tại thời điểm transition. Nếu có 2 element cùng name, transition sẽ fail silently.

Dynamic view-transition-name cho list items

Trong danh sách sản phẩm, mỗi card cần name riêng:

// Gán dynamic name dựa trên product ID
products.forEach(product => {
  const card = document.querySelector(`[data-id="${product.id}"]`);
  card.querySelector('img').style.viewTransitionName =
    `product-hero-${product.id}`;
});

Hoặc với inline style trong template:

<article class="product-card">
  <img src="..." style="view-transition-name: product-hero-42;">
  <h3 style="view-transition-name: product-title-42;">Giày Nike</h3>
</article>

Customize animation với CSS

View Transitions tạo pseudo-element tree:

::view-transition
├── ::view-transition-group(name)
│   └── ::view-transition-image-pair(name)
│       ├── ::view-transition-old(name)  ← screenshot cũ
│       └── ::view-transition-new(name)  ← screenshot mới

Bạn có thể style bất kỳ level nào:

/* Shared element transition: chỉ animate position + size, không fade */
::view-transition-group(product-hero) {
  animation-duration: 300ms;
  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

/* Stagger animation cho list items */
::view-transition-group(card-1) { animation-delay: 0ms; }
::view-transition-group(card-2) { animation-delay: 50ms; }
::view-transition-group(card-3) { animation-delay: 100ms; }

/* Respect user preference */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 0.01ms !important;
  }
}

💡 Luôn handle prefers-reduced-motion. Transition đẹp nhưng với một số user (motion sensitivity, vestibular disorders), animation có thể gây khó chịu.

Callback API — kiểm soát lifecycle

startViewTransition() trả về object với các promise hữu ích:

const transition = document.startViewTransition(() => {
  updateDOM();
});

// Chờ old state đã được capture
await transition.ready;
// → Tại đây bạn có thể chạy custom Web Animations API

// Chờ animation hoàn tất
await transition.finished;
// → Cleanup, analytics tracking, etc.

// Skip animation nếu cần
transition.skipTransition();

Ứng dụng thực tế — animation tuỳ theo hướng navigate:

async function navigate(url, direction = 'forward') {
  const transition = document.startViewTransition(() => {
    updateContent(url);
  });

  await transition.ready;

  // Animate khác nhau tuỳ forward/back
  document.documentElement.animate(
    direction === 'forward'
      ? [{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }]
      : [{ transform: 'translateX(-100%)' }, { transform: 'translateX(0)' }],
    {
      duration: 250,
      easing: 'ease-out',
      pseudoElement: '::view-transition-new(root)',
    }
  );
}

Hiệu năng — tại sao nhanh hơn JS animation

View Transitions chạy trên compositor thread, tách biệt khỏi main thread. Nghĩa là:

  • Animation không bị jank khi main thread đang parse JS
  • Snapshot là bitmap — animate bitmap rẻ hơn animate DOM elements
  • Browser tự optimize: skip frames thông minh, GPU-accelerated

So sánh với Framer Motion AnimatePresence:

Tiêu chí View Transitions API Framer Motion
Bundle size 0KB ~32KB gzipped
Main thread Không block Block (JS animation)
MPA support Không
Browser support Chrome 111+, Safari 18+ Mọi browser
Flexibility CSS + Web Animations API Full JS control

Browser support và progressive enhancement

Tính đến tháng 3/2026:

  • Chrome/Edge 111+: Full support (SPA + MPA)
  • Safari 18+: SPA support, MPA đang triển khai
  • Firefox: Đang implement

Pattern an toàn:

function safeNavigate(url) {
  if (!document.startViewTransition) {
    // Fallback: chuyển trang bình thường
    window.location.href = url;
    return;
  }

  document.startViewTransition(() => {
    updateContent(url);
  });
}

Với MPA, @view-transition { navigation: auto } tự động bị bỏ qua trên browser chưa support. Progressive enhancement hoàn hảo — không cần feature detection.

Khi nào nên dùng

Dùng:

  • Navigation giữa các trang/view — tạo cảm giác liền mạch
  • Shared element transitions (ảnh từ list → detail)
  • Blog, e-commerce, portfolio — nơi UX mượt tạo khác biệt

Chưa nên dùng:

  • Animation phức tạp cần choreography giữa nhiều elements
  • Khi cần support Firefox (tạm thời)
  • Micro-interactions trong cùng 1 view — dùng CSS transitions/animations thường

View Transitions API không thay thế animation libraries cho mọi use case. Nhưng cho page transitions — thứ mà trước đây cần SPA framework + animation library — giờ 3 dòng CSS là đủ. Web platform đang dần bắt kịp những gì native app làm được từ lâu.


Có câu hỏi hay góp ý? Reach out trên Twitter hoặc GitHub.