Isn’t it pretty? Give it a try!
I first saw this effect over at driftime.com and my first thought was, COOL! How have they done that? I loved how the new theme morphed out from where I clicked. I knew I needed to replicate it. This post breaks down the steps I took to make it happen.
If you want to skip the walkthrough, the accordion below has the full implementation.
export const THEME = {
light: 'light',
dark: 'dark',
system: 'system',
} as const;
export type Theme = (typeof THEME)[keyof typeof THEME];<script is:inline define:vars={{ THEME }}>
(function () {
const theme = localStorage.getItem('theme') ?? THEME.system;
const dark =
theme === THEME.dark ||
(theme === THEME.system && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = theme;
// Keep .dark for compatibility with existing `dark:` Tailwind utilities.
document.documentElement.classList.toggle(THEME.dark, dark);
})();
</script>---
import { THEME } from '@/constants/theme';
---
<div class="theme-toggle" data-theme-toggle role="group" aria-label="Color scheme">
<button type="button" class="theme-toggle__button" data-theme-value={THEME.light} aria-label="Light mode" aria-pressed="false"><!-- sun icon --></button>
<button type="button" class="theme-toggle__button" data-theme-value={THEME.dark} aria-label="Dark mode" aria-pressed="false"><!-- moon icon --></button>
<button type="button" class="theme-toggle__button" data-theme-value={THEME.system} aria-label="System mode" aria-pressed="false"><!-- monitor icon --></button>
</div>
<style>
.theme-toggle {
border: 1px solid var(--theme-toggle-border);
border-radius: 9999px;
background-color: var(--theme-toggle-bg);
}
.theme-toggle__button {
color: var(--theme-toggle-icon);
}
.theme-toggle__button:hover {
color: var(--theme-toggle-icon-hover);
}
.theme-toggle__button[data-active] {
background-color: var(--theme-toggle-active-bg);
}
</style>
<script>
import { setupTheme } from '../lib/theme';
setupTheme();
</script>import { THEME, type Theme } from '@/constants/theme';
export type { Theme };
const STORAGE_KEY = 'theme';
export function getStoredTheme(): Theme {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === THEME.light || stored === THEME.dark || stored === THEME.system) {
return stored;
}
return THEME.system;
}
export function isDark(theme: Theme): boolean {
if (theme === THEME.dark) return true;
if (theme === THEME.light) return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
export function isDarkFromDom(): boolean {
return isDark(document.documentElement.dataset.theme);
}
function prefersReducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function setTransitionOrigin(origin: HTMLElement) {
const rect = origin.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const radius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
);
document.documentElement.style.setProperty('--theme-circle-x', `${x}px`);
document.documentElement.style.setProperty('--theme-circle-y', `${y}px`);
document.documentElement.style.setProperty('--theme-circle-radius', `${radius}px`);
}
export function applyTheme(theme: Theme, options?: { origin?: HTMLElement }) {
const willBeDark = isDark(theme);
const currentTheme = (document.documentElement.dataset.theme as Theme | undefined) ?? THEME.system;
const isCurrentlyDark = isDark(currentTheme);
const update = () => {
localStorage.setItem(STORAGE_KEY, theme);
document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle(THEME.dark, willBeDark);
syncToggles(theme);
document.dispatchEvent(
new CustomEvent('themechange', { detail: { theme, dark: willBeDark } }),
);
};
const origin = options?.origin;
const shouldAnimate =
origin &&
isCurrentlyDark !== willBeDark &&
!prefersReducedMotion() &&
typeof document.startViewTransition === 'function';
if (shouldAnimate) {
setTransitionOrigin(origin);
document.startViewTransition(update);
return;
}
update();
}
function syncToggles(theme: Theme) {
for (const root of document.querySelectorAll('[data-theme-toggle]')) {
for (const button of root.querySelectorAll('[data-theme-value]')) {
const value = (button as HTMLElement).dataset.themeValue;
const active = value === theme;
button.setAttribute('aria-pressed', String(active));
button.toggleAttribute('data-active', active);
}
}
}
let initialized = false;
export function setupTheme() {
if (initialized) return;
initialized = true;
applyTheme(getStoredTheme());
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (getStoredTheme() === THEME.system) applyTheme(THEME.system);
});
document.addEventListener('click', (event) => {
const button = (event.target as Element).closest('[data-theme-value]');
if (!button?.closest('[data-theme-toggle]')) return;
applyTheme((button as HTMLElement).dataset.themeValue as Theme, {
origin: button as HTMLElement,
});
});
}@custom-variant dark (&:where(.dark, .dark *));
:root {
color-scheme: light dark;
--text-color: light-dark(var(--color-ink), var(--color-neutral-300));
--text-muted: light-dark(var(--color-neutral-600), var(--color-neutral-400));
--bg-color: light-dark(var(--color-gray-light), var(--color-ink-black));
--content-border-color: light-dark(var(--color-neutral-200), var(--color-neutral-800));
--theme-toggle-border: light-dark(var(--color-neutral-200), var(--color-neutral-700));
--theme-toggle-icon: light-dark(var(--color-neutral-600), var(--color-neutral-400));
--theme-toggle-icon-hover: light-dark(var(--color-neutral-900), var(--color-neutral-100));
--theme-toggle-active-bg: light-dark(var(--color-neutral-200), var(--color-neutral-700));
/* ... */
}
[data-theme='light'] {
color-scheme: light;
}
[data-theme='dark'] {
color-scheme: dark;
}
.dark .body-fixed-bg {
/* background pattern that can't use light-dark() in a data URI */
}
html {
view-transition-name: root;
}
::view-transition-old(root) {
animation: none;
}
::view-transition-new(root) {
animation: 0.75s cubic-bezier(0.5, 0, 0.1, 1) theme-circle-reveal;
}
@keyframes theme-circle-reveal {
from {
clip-path: circle(0 at var(--theme-circle-x) var(--theme-circle-y));
}
to {
clip-path: circle(var(--theme-circle-radius) at var(--theme-circle-x) var(--theme-circle-y));
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}import { isDarkFromDom } from '@/lib/theme';
const UTTERANCES_ORIGIN = 'https://utteranc.es';
export type UtterancesTheme = 'github-light' | 'github-dark';
export function utterancesTheme(): UtterancesTheme {
return isDarkFromDom() ? 'github-dark' : 'github-light';
}
export function syncUtterancesTheme() {
const iframe = document.querySelector<HTMLIFrameElement>('iframe.utterances-frame');
if (!iframe?.contentWindow) return;
iframe.contentWindow.postMessage(
{ type: 'set-theme', theme: utterancesTheme() },
UTTERANCES_ORIGIN,
);
}
export function setupUtterances(anchor: HTMLElement) {
// ...create script with theme={utterancesTheme()}
document.addEventListener('themechange', syncUtterancesTheme);
} Creating the toggle
The first thing we need is a light and dark mode, and a way to toggle between the two. I prefer to have dark mode toggled at the OS level on my laptop so that it applies across all my apps, as well as immediately on navigating to a new website that supports it. This means I don’t have to click dark mode each time I load a new site. Convenient.
I also want to let the user choose a specific theme on the site itself, so they can override their OS setting.
:root {
color-scheme: light dark;
--text-color: light-dark(var(--color-ink), var(--color-neutral-300));
--text-muted: light-dark(var(--color-neutral-600), var(--color-neutral-400));
/* ... */
}
[data-theme='light'] {
color-scheme: light;
}
[data-theme='dark'] {
color-scheme: dark;
}
I use the light-dark() utility function to to create CSS variables that respond to light and dark mode
without having to re-declare the variables in a specific dark mode selector. color-scheme states our page
supports both light and dark mode and the mode should be set by the value of the prefers-color-scheme media condition.
To also support the user being apply to choose a specific theme, we add a data-theme attribute to <html> and
then set the color-scheme based on that. By default, we set data-theme to 'system'. Read more about all this here.
For ease, I’m also adding a .dark class to <html>, which will make doing certain non-color styling easier in dark/light mode (such as background images).
As I’m using Astro, I mostly use scoped style tags for CSS. However, to be feature complete, I’ve also got a custom darkmode variant in Tailwind: @custom-variant dark (&:where(.dark, .dark *));.
This let’s us style for dark mode using the class variant dark: in front of styles.
We now need a little component to toggle between the themes:
---
import { THEME } from '@/constants/theme';
import LightSvg from './LightSvg.astro';
import DarkSvg from './DarkSvg.astro';
import SystemSvg from './SystemSvg.astro';
---
<div
class="inline-flex items-center"
data-theme-toggle
role="group"
aria-label="Color scheme"
>
<button type="button" data-theme-value={THEME.light} aria-label="Light mode" aria-pressed="false">
<LightSvg />
</button>
<button type="button" data-theme-value={THEME.dark} aria-label="Dark mode" aria-pressed="false">
<DarkSvg />
</button>
<button type="button" data-theme-value={THEME.system} aria-label="Match system appearance" aria-pressed="true">
<SystemSvg />
</button>
</div>
For accessibility, I included aria-label so users with screen readers can understand the button, as well as aria-pressed to know which toggle is turned on.
We can now update the JavaScript to get the system preference and apply it on page load:
<script>
function isDark(theme) {
if (theme === THEME.dark) return true;
if (theme === THEME.light) return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function applyTheme(theme) {
/** Update the theme */
document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle('dark', isDark(theme));
localStorage.setItem('theme', theme);
/** Update the Theme Toggles across the site */
const buttons = document.querySelectorAll('[data-theme-value]');
buttons.forEach(button => {
const active = button.dataset.themeValue === theme;
button.setAttribute('aria-pressed', String(active));
button.toggleAttribute('data-active', active);
});
}
const theme = localStorage.getItem('theme') ?? THEME.system;
applyTheme(theme);
addThemeToggleEventListeners(applyTheme); // Click handler for theme toggle + 'change' listener for `prefers-color-scheme` media matcher
This now defaults the site to dark mode if the OS is set to dark mode. Great! But wait, I’m still getting a flash of light when the page is loading. That definitely needs fixing.
Fixing flash of wrong colour on page load
Our astro island’s hydration comes after the page has loaded, after the browser has already painted.
To fix this, I use an IIFE Immediately Invoked Function Expression — a function that runs as soon as it is defined in my BaseHead.astro file,
which is loaded on every page in the <head> section.
<script is:inline define:vars={{ THEME }}>
(function () {
const theme = localStorage.getItem('theme') ?? THEME.system;
const dark =
theme === THEME.dark ||
(theme === THEME.system && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add(THEME.dark);
document.documentElement.dataset.theme = theme;
})();
</script>
This IIFE runs before first paint, and sets the data-theme attribute on the <html> element, and if needed, the .dark class on the body.
It does this by first checking localStorage in case a theme has been selected previously. And if not then it checks the user’s colour scheme preference and uses that.
Adding the expanding circle
Now for the exciting part, figuring out how this circular reveal works.
My initial worries are that, as this Astro site is an MPA, and the Driftime site is a Next.js app with a client router,
it may not work and I’ll have to add Astro’s <ClientRouter> and turn it into an SPA, which I didn’t want to do.
Opening the developer console on https://driftime.com and clicking the theme toggle shows a view-transition pseudo-element.
I haven’t worked with the View Transitions API before so this is the perfect opportunity to learn about them.
View Transitions API
I remembered a guide from Google that I had watched a couple of years ago, so I had a look in my Obsidian vault to see if I stored it, and there it was: https://developer.chrome.com/docs/web-platform/view-transitions
The good news is that the View Transitions API works for MPAs as well as SPAs! No <ClientRouter> needed.
We’re looking to create a “same document” view transition, which is triggered with the document.startViewTransition() method.
This has a dedicated documentation page here: https://developer.chrome.com/docs/web-platform/view-transitions/same-document
function handleClick(e) {
// Fallback for browsers that don't support this API:
if (!document.startViewTransition) {
updateTheDOMSomehow();
return;
}
// With a View Transition:
document.startViewTransition(() => updateTheDOMSomehow());
}
The google page explains how this function works well:
When
.startViewTransition()is called, the API captures the current state of the page. This includes taking a snapshot.Once complete, the callback passed to .startViewTransition() is called. That’s where the DOM is changed. Then, the API captures the new state of the page.
Once the new state is captured, the API constructs a pseudo-element tree like this:
::view-transition └─ ::view-transition-group(root) └─ ::view-transition-image-pair(root) ├─ ::view-transition-old(root) └─ ::view-transition-new(root)
So it works using snapshots of the old and new states of the page, and then animating between them.
::view-transition-new(root) is a live representation of the new view. From my understanding, this means
the new view can be styled and animated using CSS, and that the default animation applied to it is a fade.
Adding a view transition to the theme toggle
We need to alter the JavaScript to wrap our applyTheme function in a callback that is passed to document.startViewTransition.
<script>
...
function applyTheme(theme) {
const willBeDark = isDark(theme);
const isCurrentlyDark = document.documentElement.classList.contains(THEME.dark);
const update = () => {
document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle('dark', willBeDark);
localStorage.setItem('theme', theme);
...
}
const shouldAnimate =
isCurrentlyDark !== willBeDark &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches() &&
typeof document.startViewTransition === 'function';
shouldAnimate ? document.startViewTransition(update) : update();
}
...
</script>
It’s important to support users who prefer reduced motion, be that just a preference or for their own safety.
We can check for this using the prefers-reduced-motion media query, as we’ve done above.
So now we’ve now got a View Transition between the theme modes, but it’s just a fade animation currently. We are missing the circular reveal.
Creating the circular reveal
So we have two view transition pseudo elements stacked on top of each other, which contain a snapshot of the current theme and the new theme. We’re also able to animate the new snapshot using CSS animations.
We’ve been doing some design work using clip-path recently, which seems like the perfect property to use here as it can also be animated,
as long as the shapes have the same number of points.
html {
view-transition-name: root;
}
::view-transition-new(root) {
animation: 0.75s cubic-bezier(0.5, 0, 0.1, 1) theme-circle-reveal;
}
@keyframes theme-circle-reveal {
from {
clip-path: circle(0 at 0 100vh);
}
to {
clip-path: circle(100vw at 0 100vh);
}
}
And there we go, we have a decent first draft of this effect.
Additional Extras
It’s not yet perfect, as there are a few other improvements we can make to the user experience for this to be even better.
- The first is tracking when the user’s OS theme changes while they’re on the page. We can do that with something like:
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (currentTheme === THEME.system) applyTheme(THEME.system);
});
- The second is to sync the theme to Utterances, which manages comments on these posts. Utterances can receive a
postMessagewithtype: 'set-theme'and the theme name which can change it between light and dark mode after loading.
I keep the logic for making this postMessage outside of my theme code using pub/sub pattern. When the theme
changes, I emit a themechange event, and my utterances code listens for it to then make the postMessage call.
document.dispatchEvent(
new CustomEvent('themechange', { detail: { theme, dark: willBeDark } }),
);
document.addEventListener('themechange', syncUtterancesTheme);
- And finally, we may have multiple theme toggles, so we want the clip-path to reveal from the theme toggle element as the point of origin, with a more specific radius than 100vw, so it covers all screen sizes.
function setTransitionOrigin(origin: HTMLElement) {
const rect = origin.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const radius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
);
document.documentElement.style.setProperty('--theme-circle-x', `${x}px`);
document.documentElement.style.setProperty('--theme-circle-y', `${y}px`);
document.documentElement.style.setProperty('--theme-circle-radius', `${radius}px`);
}
/** applyTheme get `event.target` as options.origin to set the transition origin */
export function applyTheme(theme: Theme, options?: { origin?: HTMLElement }) {
// ...
if (shouldAnimate) {
setTransitionOrigin(options.origin);
document.startViewTransition(update);
return;
}
}
@keyframes theme-circle-reveal {
from {
clip-path: circle(0 at var(--theme-circle-x) var(--theme-circle-y));
}
to {
clip-path: circle(var(--theme-circle-radius) at var(--theme-circle-x) var(--theme-circle-y));
}
}
You can check the code on GitHub to see it all in-situ, or expand the code accordion at the top of this article for everything in one place.
Post thoughts
When I first saw this effect, I thought the circular reveal was going to be the most complicated part. But having worked through it, as with many things in tech, the complexity was in the details of the user experience, and in connecting all the elements together.
I’m pleased with the outcome, and hope it surprises and delights someone in the way it originally did for me.
I hope you enjoyed the post!
Comments