Animated Dark Mode Toggle with View Transitions API in React ✨
Nowadays we use dark mode everywhere. But let’s be honest — when we toggle dark mode, most implementations just snap from light to dark, leaving users with a jarring jump cut that feels… meh. What if instead we could animate the entire page with a circle of darkness (or light) blooming from the button itself, spreading across the screen like magic?
Well, thanks to the View Transitions API, React’s flushSync
, some geometric math that as a child we all used to say where we will use this, and CSS clip-path
, we can pull off exactly that. 🚀
Let’s break it down.
The Goal: A Circular Dark Mode Reveal 🌗
Imagine clicking your theme toggle and instead of a harsh flicker, you see a circle of darkness (or light) blooming from the button itself, spreading across the screen like magic ink. That’s the vibe we’re going for.
And the best part? It’s not a massive animation library. It’s just a handful of modern web APIs working together.
first explore the core function to understand the code:
const toggleDarkMode = async () => {
// if the browser doesn't support the View Transitions API or the user prefers reduced motion, we just flip the theme instantly
if (
!ref.current ||
!document.startViewTransition ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
setTheme(isDarkMode ? 'light' : 'dark')
return
}
// if the browser supports the View Transitions API, we start the view transition
await document.startViewTransition(() => {
// we flush the sync to ensure the DOM is updated immediately
flushSync(() => {
setTheme(isDarkMode ? 'light' : 'dark')
})
}).ready
//we get the bounding client rect of the toggle
const { top, left, width, height } = ref.current.getBoundingClientRect()
// we calculate the center of the toggle
const x = left + width / 2
const y = top + height / 2
// we calculate the right of the toggle
const right = window.innerWidth - left
// we calculate the bottom of the toggle
const bottom = window.innerHeight - top
// we calculate the max radius of the circle
const maxRadius = Math.hypot(Math.max(left, right), Math.max(top, bottom))
// we animate the clip-path of the root element
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)',
}
)
}
Looks neat, right? Now let’s break down each piece of the puzzle.
Step 1. Meet the View Transitions API 🎬
View Transitions API is a new API that allows you to animate the transition between two states of the DOM. Basically what it does is it takes a snapshot of the current state of the DOM, lets you change the DOM, and then animates to the new state.
With the help of this API, we can do the lots of amazing animations.
For more information about the View Transitions API, you can check out the MDN documentation .
Step 2. flushSync
— Forcing React to update the DOM immediately 🔥
React usually batches updates. That’s great for performance, but for the view transitions API we need to update the DOM immediately.
flushSync
is like saying: “React, no waiting, update the DOM now.” So that the view transitions API can capture the new state of the DOM immediately.
Why? Because View Transitions needs to capture the new DOM immediately. If React batches the update here, the transition won’t have the right snapshot and the magic breaks.
So yes, flushSync
is a bit of a diva, but we only use it in special moments like this.
we only use flushSync
in special moments like this.
Step 3. Finding the Toggle Center 🎯
first we need to find out the where the toggle button is located in the viewport. so we use getBoundingClientRect()
to get the position of the toggle button.
It gives us top
, left
, width
, height
, etc. With those, we calculate the center:
// we calculate the exact center of the toggle button
const x = left + width / 2
const y = top + height / 2
Now we know exactly where the circle should start from.
Step 4. How Big Should the Circle Be? 🧮
We need the circle to grow big enough to cover the whole viewport.
But how big should the circle be? Thats the question and we have to find the answer.
This is where we use Math.hypot
to calculate the hypotenuse — essentially the diagonal distance to the farthest corner from our toggle button.
We measure how far the toggle is from the edges of the viewport, using Math.hypot
to calculate the hypotenuse.
The Pythagorean equation is:
Where:
- is the horizontal distance from the toggle to the farthest vertical edge
- is the vertical distance from the toggle to the farthest horizontal edge
- is the diagonal distance to the farthest corner
const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top)
)
Here’s what’s happening:
Math.max(left, window.innerWidth - left)
finds the greater distance to either the left or right edgeMath.max(top, window.innerHeight - top)
finds the greater distance to either the top or bottom edgeMath.hypot
calculates the diagonal distance using the Pythagorean theorem:sqrt(a² + b²)
Why Math.hypot
? Because it calculates d =
— the cleanest way to measure the diagonal distance. Now our circle is guaranteed to cover the entire screen, no matter where the toggle is positioned.
Step 5. Clip-Path Animation ✂️
since we already calculated the radius of the circle, we can now animate the clip-path of the root element to reveal the new theme.
// we animate the clip-path of the root element
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`, // starts at 0px (invisible)
`circle(${maxRadius}px at ${x}px ${y}px)`, // expands to cover the entire viewport
],
},
{
duration: 500,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)', // we target the new theme snapshot
}
)
- Starts at
0px
(invisible). - Expands to
maxRadius
(covers the entire viewport). - Targets
::view-transition-new(root)
so only the new theme snapshot is revealed.
Result: a blooming dark (or light) circle that makes your UI feel alive and responsive.
Last part of the puzzle 🎯
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
This snippet is the final piece for controlling the view transition effect in modern browsers. Here’s what it does:
::view-transition-old(root)
targets the old content during a view transition::view-transition-new(root)
targets the new content being inserted
Inside both selectors:
animation: none;
- This disables any inherited CSS animations on the transitioning elements, preventing unwanted flickers or movementsmix-blend-mode: normal;
- This ensures that the new and old content render normally on top of each other rather than blending colors in unexpected ways
In short, this CSS ensures that the transition looks clean and stable, without extra animations or color blending interfering with the effect. It’s like telling the browser: “Just swap these layers smoothly, don’t add any extra visual tricks.”
Why This Feels So Good ❤️
Instead of a disconnected flicker, you get an amazing animation and experience. The toggling button feels the like magic the way it covers the entire screen slowly 🤌.
It’s not only functional — it’s fun to implement features like this 😭.
Want to Try It Yourself?
- Copy the function into your React project.
- Wrap your app in a theme provider (e.g.,
next-themes
or your own state). - Add the toggle button and ref.
- Flip between light and dark with style. 🌙☀️
And don’t forget to test with reduced-motion settings — always respect user preferences.
Toggle the theme of this site for feel the magic.
Links & Docs 📚
- View Transitions API on MDN
- flushSync React docs
- getBoundingClientRect on MDN
- Math.hypot on MDN
- clip-path basics on MDN
So next time someone clicks your dark mode toggle, don’t just switch themes — put on a show. 🎉
Comments
Leave a comment or reaction below!