I use the View Transition API to create animated transitions for dark mode on this website. It’s actually very easy to implement, without needing any fancy lib or dependency. All I need is just a few lines of code and CSS styling.
The View Transition API provides a method to handle seamless transitions between different views on the website. This allows animation styles to be created between two states of visual DOM changes. The changes can range from something simple such as adding a new element to the DOM, to something much bigger such as navigating between pages.
Here is the snippet of code to run view transition:
1
2
3
4
5
6
7
8
9
function toggleTheme(theme) {
if (!document.startViewTransition) {
setTheme(theme);
return;
}
document.startViewTransition(() => {
setTheme(theme);
});
}
View transitions work in the following way:
An event to view transition is triggered. This can be achieved depending on the types of transition: For Single Page Application (SPA), the event can be triggered by calling the document.startViewTransition()
method. A function to update the DOM can be created as a callback to the method. For Multi Page Application (MPA), the event can be triggered simply by navigating the same-origin pages. There is no API call to start a cross-document view transition. Both current and destination pages need to opt-in by using a @view-transition
at-rule in their CSS with a navigation descriptor of auto
.
The browser captures visual snapshots of any DOM elements before changes are applied.
The DOM gets updated. For SPA, the callback inside startViewTransition()
runs, applying DOM updates. After this, ViewTransition.updateCallbackDone
resolves — allowing DOM updates to be handled. For MPA, the browser navigates to the new page.
After the DOM updates (SPA) or the new page loads (MPA), the browser takes new snapshots of the target view for animation. The ViewTransition.ready
promise is fulfilled, allowing a custom animation to be run.
At this point, the transition between the old view and new view occurs. The old view is animated by fading out (opacity 1 -> 0) while the new view is animated by fading in (opacity 0 -> 1). This creates a seamless cross-fade effect by default.
When the animation ends, ViewTransition.finished
resolves — this can be used to trigger any final logic after the transition is complete.
However, currently the View Transition API is not supported in Mozilla Firefox yet.
To handle the transition between inbound and outbound animation, the API constructs a pseudo-element with structure like this:
1
2
3
4
5
::view-transition
└─ ::view-transition-group(root)
└─ ::view-transition-image-pair(root)
├─ ::view-transition-old(root)
└─ ::view-transition-new(root)
The ::view-transition
pseudo-element represents the top-level overlay used during a view transition. This element sits above all other page content and holds the snapshots of both the old and new views during the transition. It essentially acts as the animation stage.
Each snapshot (old and new) is grouped using the ::view-transition-group()
pseudo-element. You can think of this as a wrapper that groups elements participating in the transition. By default, the :root
element is used as the root snapshot group because browsers automatically set the view-transition-name
property on it.
During a transition, the browser captures two versions of your content:
The ::view-transition-old()
pseudo-element targets the snapshot of the previous state (before the DOM change).
The ::view-transition-new()
pseudo-element targets the new state (after the DOM change).
The ::view-transition-image-pair()
pseudo-element serves as a container that holds both the old and new snapshots of a transitioning element. It's automatically inserted into the pseudo-element tree by the browser during a view transition and always exists as a child of ::view-transition-group()
.
All of the view transition pseudo-elements can be styled with CSS, allowing full customization of the animation sequence. For instance:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
::view-transition-group(root) {
animation-timing-function: ease;
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: anim 3s;
}
::view-transition-new(root) {
mask: url('https://media.tenor.com/cyORI7kwShQAAAAi/shigure-ui-dance.gif') center / 0 no-repeat;
animation: anim 3s;
}
@keyframes anim {
0% {
mask-size: 0;
}
10%,
90% {
mask-size: 50vmax;
}
100% {
mask-size: 2000vmax;
}
}