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 View Transitions API coming to the browsers.

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:

  1. 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.
  2. Then, the API runs some code that updates the page to the next state which it should animate to.
  3. 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.
  4. With the “Old” and “New” screenshots of the page, the browser loads these in ::view-transition-old and ::view-transition-new pseudo-elements.
  5. Finally, a CSS animation is run between these two pseudo-elements which reveals the ::view-transition-new pseudo-element.
  6. 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.
DOM in memory
Red
Transition root
What user sees
Red
Developer calls 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 Radix UI for simplicity but you can use any component library/custom switch component you might have.

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() function that synchronously applies all the DOM updates once the state variables have been updated. The React docs for this function show a big warning that using it is uncommon and it might hurt performance, but it is totally fine for View Transitions API as outlined in this article.

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 function. In Svelte, it’s the tick function.

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.

Error while toggling the switch? Your browser might not support View Transition API. Try using Google Chrome instead.

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:

  1. A circle starts to grow from the position of the switch.
  2. Within the circle, the “New” state of the page is rendered.
  3. 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 property in CSS! The 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:

css
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.

jsx
1
import { useState, useEffect, useRef } from 'react';
2
3
export default function App() {
4
const [isDarkMode, setIsDarkMode] = useState(false);
5
const ref = useRef(null);
6
7
const toggleDarkMode = (isDarkMode) => {
8
if (!ref.current) return;
9
10
document.startViewTransition(() => {
11
flushSync(() => {
12
setIsDarkMode(isDarkMode);
13
});
14
});
15
16
const { top, left } = ref.current.getBoundingClientRect();
17
const x = left;
18
const y = top;
19
20
document.documentElement.animate(
21
{
22
clipPath: [
23
`circle(0px at ${x}px ${y}px)`,
24
`circle(300px at ${x}px ${y}px)`,
25
],
26
},
27
{
28
duration: 500,
29
easing: 'ease-in-out',
30
pseudoElement: '::view-transition-new(root)',
31
}
32
);
33
};
34
35
// useEffect(() => { ...
36
37
return (
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:

css
1
::view-transition-old(root),
2
::view-transition-new(root) {
3
animation: none;
4
mix-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.

Error while toggling the switch? Your browser might not support View Transition API. Try using Google Chrome instead.

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.

math

This math can be implemented in code as follows:

Error while toggling the switch? Your browser might not support View Transition API. Try using Google Chrome instead.

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:

  1. The circular mask currently grows from the top-left of the switch because we are using the top and the left offsets from getBoundingClientRect(). In my opinion, the center of the circle should be at the center of the switch, so we can calculate the new offset by adding height / 2 and width / 2 to the top and left respectively.
  2. At the time of writing this article, View Transitions API is experimental and not supported in all the major browsers out there(check support here). To prevent the interaction from breaking on these browsers, we can add a check that calls setIsDarkMode() and returns early.
  3. 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

Error while toggling the switch? Your browser might not support View Transition API. Try using Google Chrome instead.

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.

References

If you found this article helpful, you will love these too.

Building an expressive API for custom confirm dialogs in React

Building highlighted input field in React

12 min read
Building highlighted input field in React
My look at SvelteJS and how you can start using it