A simple dark/light mode toggle with a smooth circular zoom animation, made using the View Transition API, vanilla HTML, and CSS. No frameworks, no libraries โ just pure frontend experimentation.
The View Transition API captures the "before" and "after" snapshots of the DOM and lets you animate between them.
startViewTransition()
captures the current UI (snapshot A).- Inside its callback, you update the DOM (e.g., toggle dark mode).
- Browser takes another snapshot (snapshot B).
- It animates from A โก๏ธ B using
::view-transition-old(root)
and::view-transition-new(root)
pseudo-elements.
It's like diffing the UI and animating the change โ super clean!
Want to create a smooth, animated theme switch like Telegram? Here's a breakdown of how to implement the View Transition API step-by-step โ with fallback support and a clean circular animation.
This API doesnโt work in Firefox or for users who prefer reduced motion. So, always add a fallback to keep the theme toggle functional:
if (!document.startViewTransition || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
body.classList.toggle('dark-mode');
return;
}
We want the animation to expand from where the user clicked. So first, find the center of the toggle button:
const { top, left, width, height } = button.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
To make sure the animation fully covers the screen, calculate the maximum radius required:
const right = window.innerWidth - left;
const bottom = window.innerHeight - top;
const maxRadius = Math.hypot(Math.max(left, right), Math.max(top, bottom));
Now we use the View Transition API to apply the theme change inside a transition. This lets the browser capture the before/after states and animate between them.
await document.startViewTransition(() => {
body.classList.toggle('dark-mode');
}).ready;
Now apply a circular clip-path animation on the new visual state (pseudo element) starting from the button center:
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)',
}
);
Donโt forget this part. Without this, your animation might get unexpected behavior or blend weirdly:
/* Required for View Transition to behave as expected */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}