- Thay vì dùng CSS transition đơn thuần, View Transitions API kết hợp clip-path tạo hiệu ứng wipe hoặc circular reveal khi đổi theme.
- API đạt Baseline 2025, hỗ trợ Chrome 111+, Firefox 144+, Safari 18+ - ~90% trình duyệt.
- Kỹ thuật dùng chưa tới 20 dòng JS, không cần animation library.
- Tích hợp sẵn trong React canary qua component <ViewTransition>.
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:
- Chụp screenshot trạng thái hiện tại (
::view-transition-old(root)) - Chạy
callback- DOM cập nhật sang theme mới - Chụp screenshot trạng thái mới (
::view-transition-new(root)) - Mount cả hai pseudo-element lên overlay phủ toàn trang
- 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ị conflictmix-blend-mode: normal: overrideplus-lightermặ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:
| Framework | Cách đồng bộ DOM |
|---|---|
| React | flushSync() từ react-dom |
| Vue.js | await nextTick() |
| Svelte | await tick() |
| Angular 17+ | withViewTransitions trong @angular/router |
| Vanilla JS | Khô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.