TL;DR

Dark mode toggle kiểu cũ: flip class, màu nền flash một cái, xong. Kiểu mới: browser chụp snapshot trạng thái cũ & mới, rồi animate một mask hình học ra toàn màn hình trong 600ms. Không cần library. Không cần CSS phức tạp. Chỉ cần View Transitions API + clip-path.

Snippet từ @mannupaaji:

document.documentElement.animate(
  { clipPath: ["inset(0 0 100% 0)", "inset(0)"] },
  { pseudoElement: "::view-transition-new(root)", duration: 600 },
)

::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

Kết quả: theme mới "kéo rèm" từ trên xuống dưới thay vì bật tắt đột ngột.

Vấn đề với CSS transition thuần

Cách triển khai dark mode phổ biến nhất:

:root { transition: background-color 0.3s, color 0.3s; }
.dark { background: #0a0a0a; color: #fff; }

Trông ổn trong demo, nhưng có một số vấn đề thực tế:

  • Chỉ animate được màu sắc, không animate được layout hay ảnh
  • Khi đổi theme, cả old & new state đều trong DOM cùng lúc - gây focus confusion cho screen reader
  • Hiệu ứng fade đơn điệu, không có chiều sâu về spatial context
  • Không thể tạo animation "từ điểm click" vì CSS không biết vị trí nút toggle

View Transitions API giải quyết tất cả: browser quản lý snapshot, DOM sạch, JS tính tọa độ động.

Cơ chế hoạt động

Khi gọi document.startViewTransition(callback), browser thực hiện 5 bước:

  1. Chụp screenshot trạng thái hiện tại (::view-transition-old(root))
  2. Chạy callback - DOM cập nhật sang theme mới
  3. Chụp screenshot trạng thái mới (::view-transition-new(root))
  4. Mount cả hai pseudo-element lên overlay phủ toàn trang
  5. Chạy animation giữa old & new, rồi unmount khi xong

Mặc định sẽ là cross-fade. Để customize, ta animate trực tiếp lên pseudo-element ::view-transition-new(root) bằng Web Animations API.

Hai phong cách clip-path

Phong cách 1 - Wipe từ trên xuống (snippet của @mannupaaji, đơn giản nhất):

// Inject sau khi startViewTransition
document.documentElement.animate(
  { clipPath: ["inset(0 0 100% 0)", "inset(0)"] },
  { pseudoElement: "::view-transition-new(root)", duration: 600 },
)

inset(0 0 100% 0) nghĩa là clip hết phần bottom (100%), rồi animate về inset(0) - lộ toàn bộ. Kết quả là theme mới kéo xuống như rèm cửa.

Phong cách 2 - Circular reveal từ điểm click (phổ biến hơn, nổi tiếng qua Telegram app):

await document.startViewTransition(() => {
  flushSync(() => setIsDarkMode(v => !v)) // React
}).ready

const { top, left, width, height } = toggleRef.current.getBoundingClientRect()
const x = left + width / 2
const y = top + height / 2
const maxRadius = Math.hypot(
  Math.max(left, window.innerWidth - left),
  Math.max(top, window.innerHeight - top),
)

document.documentElement.animate(
  { clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${maxRadius}px at ${x}px ${y}px)`] },
  { duration: 500, easing: "ease-in-out", pseudoElement: "::view-transition-new(root)" },
)

Điểm quan trọng: maxRadius tính bằng định lý Pythagoras - đường chéo từ nút toggle đến góc xa nhất viewport. Đảm bảo circle luôn cover 100% màn hình dù toggle ở bất kỳ vị trí nào.

CSS bắt buộc thêm

::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

Hai dòng này làm gì:

  • animation: none: tắt cross-fade mặc định để custom animation không bị conflict
  • mix-blend-mode: normal: override plus-lighter mặc định - nếu không set, màu light & dark blend lẫn nhau trông rất kỳ

Tích hợp vào framework

Mỗi framework có cách riêng để force DOM update đồng bộ trước khi View Transitions API chụp snapshot:

FrameworkCách đồng bộ DOM
ReactflushSync() từ react-dom
Vue.jsawait nextTick()
Svelteawait tick()
Angular 17+withViewTransitions trong @angular/router
Vanilla JSKhông cần - DOM update ngay trong callback

React đặc biệt cần chú ý vì batch DOM updates bất đồng bộ. Nếu không có flushSync, startViewTransition chụp snapshot trước khi React apply class dark mode - animation sẽ không có gì để show.

Tin tốt: React team đã ship <ViewTransition> component vào react@canary (2025), xử lý tự động vấn đề này.

Xử lý accessibility

const toggleTheme = async () => {
  // Fallback: không hỗ trợ API hoặc user bật reduced-motion
  if (
    !document.startViewTransition ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  ) {
    applyTheme()
    return
  }
  // ... animation code
}

Luôn check prefers-reduced-motion - một số user có vấn đề về tiền đình khi xem animation. Skip animation, apply theme ngay lập tức là đủ.

Browser support & roadmap

View Transitions API đạt Baseline 2025 (Newly Available) vào tháng 10/2025 khi Firefox 144 ship. Hiện tại:

  • Chrome 111+, Edge 111+, Opera 97+ - full support từ 2023
  • Safari 18+ - support từ 2024
  • Firefox 144+ - support từ Oct 2025
  • ~89.88% global browser coverage (caniuse.com)

Những tính năng mới đáng chú ý trong 2025:

  • Chrome 137: view-transition-name: match-element - tự động đặt tên cho elements trong list, không cần manual naming
  • Chrome 140: Nested transition groups - hỗ trợ 3D transform & clipping trong transition
  • Chrome 142 (sắp tới): Scoped transitions (element.startViewTransition()) - chạy nhiều transitions cùng lúc trên các subtree khác nhau

Nguồn: Chrome for Developers, MDN, Can I Use.