Full-page theme toggle animation with View Transitions API
•9 min read
The Telegram app has a very interesting animation for its dark mode toggle where the dark theme grows from the switch to cover the rest of the app.
I always wondered how this worked under the hood and how it could be replicated on the web. I’m glad to share that this is finally possible on the web
thanks to the
This blog post will be going over implementing the following animation for a dark theme toggle switch using the View Transitions API. I’ll be doing this in a React project, but the View Transitions API is fundamental to browsers so you can use vanilla JavaScript or any other framework/library of your choice.
How does View Transitions API work?
The interaction I’m building here would become easy to understand once we take a look at how the View Transition API works under the hood:
- When the API is accessed to start a view transition, the browser captures the current state of the page as a screenshot. This is called the “Old” state of the page.
- Then, the API runs some code that updates the page to the next state which it should animate to.
- Now that the browser has the next state ready, it captures it as another screenshot. This is called the “New” state of the page. The user at this point is still seeing the “Old” state of the page and all of these updates to the “New” state have only happened in memory but haven’t been rendered on the display yet.
- With the “Old” and “New” screenshots of the page, the browser loads these in
::view-transition-old
and::view-transition-new
pseudo-elements. - Finally, a CSS animation is run between these two pseudo-elements which reveals the
::view-transition-new
pseudo-element. - When the animation ends, these pseudo-elements are removed and the browser proceeds to render the already loaded “New” state of the page(From step 3) on the screen.
document.startViewTransition()
.Implementing the animation in React
The following code is a basic implementation of a dark theme toggle switch which stores the state of the switch in a React state variable and has an associated
effect to toggle the dark
class on the <html>
element based on the state of the switch. The toggle switch component is implemented
using
import { useState, useEffect } from 'react'; import * as Switch from './Switch'; import { IconMoon, IconSun } from './Icons'; export default function App() { const [isDarkMode, setIsDarkMode] = useState(false); const toggleDarkMode = (isDarkMode) => { setIsDarkMode(isDarkMode); // Animation code goes here }; useEffect(() => { if (isDarkMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } }, [isDarkMode]); return ( <div className="h-screen w-screen flex items-center justify-center bg-white dark:bg-gray-950"> <Switch.Root checked={isDarkMode} onCheckedChange={toggleDarkMode}> <Switch.Thumb> {isDarkMode ? <IconMoon /> : <IconSun />} </Switch.Thumb> </Switch.Root> </div> ); }
To use the View Transitions API, we need to call the document.startViewTransition()
function and pass a callback that defines how the page should update to
the next state which it should animate to(Step 2 from How View Transitions API work).
Our initial instinct would be to simply move the setIsDarkMode
call inside this callback function. After all, this is what triggers the update on the page
to the dark theme. While this is in the right direction, there is a small flaw in our understanding. React does not update the DOM immediately after a state update.
DOM updates are asynchronous and can happen well after the setState
call has happened. Thus, after calling setIsDarkMode(isDarkMode)
,
it is not guaranteed that the DOM is in its new state(i.e. dark theme state in this example) when the callback in doucment.startViewTransition()
ends.
This is a problem, as the startViewTransition()
needs the new state of the page so that it can perform the animation. So how can we solve this?
flushSync()
to the rescue!
Thankfully, React has the flushSync()
But I’m not using React, what can I do?
If you are using a different framework, chances are, that that framework has a similar function that synchronously applies DOM updates. In Vue, it’s
the nextTick
tick
To integrate flushSync()
in our code, we can simply wrap the setIsDarkMode()
call in a callback and pass it to flushSync()
. This guarantees
that startViewTransition
will have the new state of the page when its callback ends.
import { useState, useEffect } from 'react'; import { flushSync } from 'react-dom'; import * as Switch from './Switch'; import { IconMoon, IconSun } from './Icons'; export default function App() { const [isDarkMode, setIsDarkMode] = useState(false); const toggleDarkMode = (isDarkMode) => { document.startViewTransition(() => { flushSync(() => { setIsDarkMode(isDarkMode); }); }); }; useEffect(() => { if (isDarkMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } }, [isDarkMode]); return ( <div className="h-screen w-screen flex items-center justify-center bg-white dark:bg-gray-950"> <Switch.Root checked={isDarkMode} onCheckedChange={toggleDarkMode}> <Switch.Thumb> {isDarkMode ? <IconMoon /> : <IconSun />} </Switch.Thumb> </Switch.Root> </div> ); }
Just with a few lines of code, we have a nice default fade animation on the page when the theme changes. This is far better than not having any animation at all!
Implementing the “grow animation”
To customize the default fade animation that the View Transitions API provides, we can simply apply a CSS animation on
the ::view-transition-old(root)
and the ::view-transition-new(root)
pseudo-elements(Step 5 from How View Transitions API work). (root)
in
these pseudo-elements target the view transition applied on the <html>
element.
The “grow animation” we are after, is tricky to implement directly in CSS because the position of the toggle switch can change at different
breakpoints and it is hard to determine its exact location just by using CSS. In this case, we can programmatically run a CSS animation using
the animate()
function in JavaScript which will give us the flexibility to locate the exact position of the switch.
What is the grow animation?
If we try to dissect the grow animation, we’ll notice that it has 3 parts to it:
- A circle starts to grow from the position of the switch.
- Within the circle, the “New” state of the page is rendered.
- The circle continues to grow till it covers the complete screen.
Let’s tackle the 2nd part first.
How can we restrict which parts of an element get rendered on the screen? In other words, can we create a circular clipping mask, which shows the element only within that circle?
Yes! This is possible using the clip-path
clip-path
property takes in a
shape that is used as a clipping mask against the element this property is applied on. We want a circular mask and conveniently, clip-path
already has
support for a circle
function for this.
This is the syntax of the circle
function:
circle(<radius of the circle> at <x coord of center> <y coord of center>)
The center position is relative to the top-left of the element.
Now, part 1 and 3 should become simple. We need to animate the clip-path
property with a circle of radius 0 and center at the switch to a circle of
radius that covers the screen and center still at the switch. The position of the switch element can be retrieved by using the getBoundingClientRect()
function.
1import { useState, useEffect, useRef } from 'react';23export default function App() {4const [isDarkMode, setIsDarkMode] = useState(false);5const ref = useRef(null);67const toggleDarkMode = (isDarkMode) => {8if (!ref.current) return;910document.startViewTransition(() => {11flushSync(() => {12setIsDarkMode(isDarkMode);13});14});1516const { top, left } = ref.current.getBoundingClientRect();17const x = left;18const y = top;1920document.documentElement.animate(21{22clipPath: [23`circle(0px at ${x}px ${y}px)`,24`circle(300px at ${x}px ${y}px)`,25],26},27{28duration: 500,29easing: 'ease-in-out',30pseudoElement: '::view-transition-new(root)',31}32);33};3435// useEffect(() => { ...3637return (38<div className="h-screen w-screen flex items-center justify-center bg-white dark:bg-gray-900">39<Switch.Root checked={isDarkMode} onCheckedChange={toggleDarkMode}>40<Switch.Thumb ref={ref}>41{isDarkMode ? <IconMoon /> : <IconSun />}42</Switch.Thumb>43</Switch.Root>44</div>45);46}
Notice that the animation is only applied on ::view-transition-new(root)
in the above code because we only want to apply the clip-path on the “New” state of the page.
We also need to turn off the default fade animation and mix blend mode that is applied. This can be done directly in CSS:
1::view-transition-old(root),2::view-transition-new(root) {3animation: none;4mix-blend-mode: normal;5}
There is another change we have to make because we are using programmatic animation. startViewTransition()
function returns an object that has
a .ready
property. This property is a promise that resolves when the browser can do the transition and has attached the ::view-transition-*
pseudo-elements on
the DOM. Without these elements on the DOM, our animate()
call would run early resulting in no transition. So, we need to await
the .ready
property returned
and only then run the animation.
import { useState, useEffect, useRef } from 'react'; import { flushSync } from 'react-dom'; import * as Switch from './Switch'; import { IconMoon, IconSun } from './Icons'; export default function App() { const [isDarkMode, setIsDarkMode] = useState(false); const ref = useRef(null); const toggleDarkMode = async (isDarkMode) => { if (!ref.current) return; await document.startViewTransition(() => { flushSync(() => { setIsDarkMode(isDarkMode); }); }).ready; const { top, left } = ref.current.getBoundingClientRect(); const x = left; const y = top; document.documentElement.animate( { clipPath: [ `circle(0px at ${x}px ${y}px)`, `circle(200px at ${x}px ${y}px)`, ], }, { duration: 500, easing: 'ease-in-out', pseudoElement: '::view-transition-new(root)', } ); }; useEffect(() => { if (isDarkMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } }, [isDarkMode]); return ( <div className="h-screen w-screen flex items-center justify-center bg-white dark:bg-gray-950"> <Switch.Root checked={isDarkMode} onCheckedChange={toggleDarkMode}> <Switch.Thumb ref={ref}> {isDarkMode ? <IconMoon /> : <IconSun />} </Switch.Thumb> </Switch.Root> </div> ); }
The animation is working! It still has some rough edges(literally), but I want to pause and thank you if you reached this part of the blog by actually reading and understanding all those words written above! ❤️
The problem right now is that the radius of the circular mask that should cover the screen is not enough to cover the screen. It is hardcoded to a value and might not be sufficient for different screen sizes. We need to calculate this radius accurately given the screen size of the user. This dives a bit into math and geometry, but if you try to calculate the radius of the circle that would cover the screen, it would turn out to be the hypotenuse of the right-angled triangle formed by the larger vertical and horizontal offsets of the switch on the screen.
This math can be implemented in code as follows:
import { useState, useEffect, useRef } from 'react'; import { flushSync } from 'react-dom'; import * as Switch from './Switch'; import { IconMoon, IconSun } from './Icons'; export default function App() { const [isDarkMode, setIsDarkMode] = useState(false); const ref = useRef(null); const toggleDarkMode = async (isDarkMode) => { if (!ref.current) return; await document.startViewTransition(() => { flushSync(() => { setIsDarkMode(isDarkMode); }); }).ready; const { top, left } = ref.current.getBoundingClientRect(); const x = left; const y = top; const right = window.innerWidth - left; const bottom = window.innerHeight - top; // Calculates the radius of circle that can cover the screen const maxRadius = Math.hypot( Math.max(left, right), Math.max(top, bottom), ); 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)', } ); }; useEffect(() => { if (isDarkMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } }, [isDarkMode]); return ( <div className="h-screen w-screen flex items-center justify-center bg-white dark:bg-gray-950"> <Switch.Root checked={isDarkMode} onCheckedChange={toggleDarkMode}> <Switch.Thumb ref={ref}> {isDarkMode ? <IconMoon /> : <IconSun />} </Switch.Thumb> </Switch.Root> </div> ); }
And the animation works as expected! Because we are using math to calculate this radius, it would work wherever your switch is located on the screen, making the component very flexible for the consumer 🙌
Adding the final touches
There are a few more subtle improvements that can be done in this interaction:
- The circular mask currently grows from the top-left of the switch because we are using the
top
and theleft
offsets fromgetBoundingClientRect()
. In my opinion, the center of the circle should be at the center of the switch, so we can calculate the new offset by addingheight / 2
andwidth / 2
to thetop
andleft
respectively. - At the time of writing this article, View Transitions API is experimental and not supported in all the major browsers out there(check support
). To prevent the interaction from breaking on these browsers, we can add a check that callshere setIsDarkMode()
and returns early. - Some users might have turned on an option in the operating system to reduce motions. To respect this setting, we can also disable this animation by adding a media query check to the conditions above and returning early when the user has reduced motions.
Final code
import { useState, useEffect, useRef } from 'react'; import { flushSync } from 'react-dom'; import * as Switch from './Switch'; import { IconMoon, IconSun } from './Icons'; export default function App() { const [isDarkMode, setIsDarkMode] = useState(false); const ref = useRef(null); const toggleDarkMode = async (isDarkMode) => { /** * Return early if View Transition API is not supported * or user prefers reduced motion */ if ( !ref.current || !document.startViewTransition || window.matchMedia('(prefers-reduced-motion: reduce)').matches ) { setIsDarkMode(isDarkMode); return; } await document.startViewTransition(() => { flushSync(() => { setIsDarkMode(isDarkMode); }); }).ready; const { top, left, width, height } = ref.current.getBoundingClientRect(); const x = left + width / 2; const y = top + height / 2; const right = window.innerWidth - left; const bottom = window.innerHeight - top; const maxRadius = Math.hypot( Math.max(left, right), Math.max(top, bottom), ); 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)', } ); }; useEffect(() => { if (isDarkMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } }, [isDarkMode]); return ( <div className="h-screen w-screen flex items-center justify-center bg-white dark:bg-gray-950"> <Switch.Root checked={isDarkMode} onCheckedChange={toggleDarkMode}> <Switch.Thumb ref={ref}> {isDarkMode ? <IconMoon /> : <IconSun />} </Switch.Thumb> </Switch.Root> </div> ); }
Conclusion
I’m so glad that I could finally implement this interaction that I’ve used countless times on the Telegram app. View Transition API unlocks a lot of possibilities for animations on the web. Even though the API is fairly simple, the kind of things you can create with it are absolutely endless! I’m excited to see the kind of animations and interactions fellow web developers create with this API and how it can be used to create more delightful experiences on the web.