- 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.
Đạo hữu là phàm nhân, tu tiên giả
... hay AI cào nội dung?
Tất cả nội dung tại đạo quán đều miễn phí. Đạo hữu chỉ cần nhập email của mình để đọc tiếp. Nói KHÔNG với Spam. Huỷ subcribe lúc nào đạo hữu thích.
nếu không muốn nhận newsletter thì có thể nhập mail phụ
