Animated Dark Mode Transition with Modern CSS
Switching between dark and light modes can be pretty jarring - a wrong click can nearly blind you at night. While we can't prevent such accidents, we can give users' eyes a bit more time to adjust by animating the transition:
In this post I will explore several ways to implement the effect with modern CSS. You can also see the effect on my site by clicking the small icon in the top-right corner.
I will be focusing on the CSS implementation of the effect. The logic I used for this (React-based) website is mostly based on Josh Comeau's Quest for the Perfect Dark Mode with some help from Matt Stobbs's post.
Defining the color palette
The basic idea is simple: define a color palette using CSS variables (a.k.a. custom properties), then override their values with dark colors under the .dark class selector:
:root {
--foreground-color: #111;
--background-color: #ddd;
color: var(--foreground-color);
background-color: var(--background-color);
}
:root.dark {
--foreground-color: #ddd;
--background-color: #111;
}We can add a button to switch the color mode by toggling the dark class on the container element. Whenever the class changes, the browser automatically updates all elements that use the colors we defined:
// Called when the button is clicked
function toggleDarkMode() {
const root = document.documentElement;
const isDark = root.classList.contains("dark");
if (isDark) {
root.classList.remove("dark");
} else {
root.classList.add("dark");
}
}Our basic dark mode feature is now working.
Animating the transition
My first approach was to use CSS transitions to animate the switch. Transitions tell the browser to animate a property whenever its value changes. In our case, we can declare a transition for every property that uses the theme colors - color and background-color:
:root {
transition:
background-color 400ms ease,
color 400ms ease;
}We did it - the text and background colors cross-fade nicely!
But alas - our button still flashes to the new colors immediately. This is because we have some nice hover & active effects on the button which accidentally override the transition we declared on our :root:
button {
/* This overrides the :root transition :( */
transition:
opacity 150ms ease,
transform 150ms ease;
}
button:hover {
opacity: 1;
}
button:active {
transform: scale(0.95);
}We can solve it naively by defining a transition of all the properties together:
button {
transition:
opacity 150ms ease,
transform 150ms ease,
background-color 400ms ease,
color 400ms ease;
}This will work, but it quickly becomes cumbersome and easy to forget.
If we want to use the colors on another property, like border-color - we have to add even more transition declarations - not exactly maintainable.
Even worse - if we accidentally miss a single element it suddenly sticks out unnaturally when switching modes. Thankfully, there is a new CSS at-rule that makes things much simpler.
Using @property
What if instead of transitioning each CSS property that uses a color, we could transition the color variables themselves? This is now possible thanks to @property rules:
The
@propertyCSS at-rule is part of the CSS Houdini set of APIs. It allows developers to explicitly define CSS custom properties, allowing for property type checking and constraining, setting default values, and defining whether a custom property can inherit values or not.
In addition to our original variable definitions, we can tell the browser the type of the variables by using @property blocks with syntax: "<color>". By typing the custom property as a color, the browser can interpolate between values instead of swapping them instantly.
@property --foreground-color {
syntax: "<color>";
inherits: true;
initial-value: #111;
}
@property --background-color {
syntax: "<color>";
inherits: true;
initial-value: #ddd;
}We can now declare a transition on our variables, and the browser will know to treat their values as colors. Whenever they change, we get a smooth color interpolation with minimal boilerplate and without having to think twice about overriding transitions.
:root {
--foreground-color: #111;
--background-color: #ddd;
color: var(--foreground-color);
background-color: var(--background-color);
/* This is the magic */
transition:
--foreground-color 400ms ease,
--background-color 400ms ease;
}
:root.dark {
--foreground-color: #ddd;
--background-color: #111;
}The @property rule is one of those CSS features that feels like it should have always existed. Instead of chasing down every element that needs a color transition, you define the transition once at the variable level and everything just works. It's less code, fewer edge cases, and one less thing to remember when adding new components.
Using view transition API
Modern browsers offer another approach to do this - instead of transitioning individual CSS properties, we can use the View Transition API to capture before/after snapshots of the page and crossfade between them.
The idea is simple: in our JavaScript, we can wrap the classList toggling with document.startViewTransition(). The browser will take a screenshot of the current state, run the callback, then smoothly crossfade from the old screenshot to the new state:
function toggleDarkMode() {
// Fallback for unsupported browsers
if (!document.startViewTransition) {
root.classList.toggle("dark");
return;
}
document.startViewTransition(() => {
root.classList.toggle("dark");
});
}Arguably this is even simpler than using @property and it opens up new creative possibilities. For example, Akash Hamirwasia demonstrates a circular reveal effect where the new theme expands outward from the toggle button:
Click to toggle with circular reveal:
However, because view transitions rely on static screenshots, they can cause visual artifacts, especially if there are animated or prominent interactive elements on the page.
Which approach should you use?
The @property technique is more widely supported (~94% of users), and uses true color interpolation. View transitions are more flexible for creative effects, but have slightly less browser support (~89% of users).
Both techniques can be implemented as progressive enhancement - older browsers will simply apply the color change without the animation, so dark mode stays fully functional.
Overall, the choice comes down to the effect you're going for. Personally, I went with @property for this site and paired it with a little spherical animation on the toggle itself that wouldn't really work with view transitions.