screenreader --> "crescent shaped moon" :)
Making a fun color-scheme toggle
One of the things I wanted to do on my own site, is play around with code, and some of the newer stuff in browsers. I hadn't done anything with dark mode before (apart from telling students it's available), so that's a nice thing to start with. I learned some stuff along the way, so time for another short piece.
I already started off with this HTML meta tag, which gives us the basics:
<meta name="color-scheme" content="dark light">
From there on, I tried to do everything without color values, custom properties, prefers-color-scheme media queries or light-dark() (which now supports background images as well!). Only using defaults in browsers for now, to see how far that gets me. I can always complicate stuff later on—and improve on the design, I agree!
Code blocks and <kbd> elements get a nice background-color, combining color-mix and currentColor. Still no color values. On my laptop I'm constantly in dark mode, but on my phone it automatically switches during the day. Been playing and tweaking for a week, works rather well.
I'm not using dark and light mode together on one page, so I'll probably not need the color-scheme CSS property. Unless I'm misunderstanding this thing, I think I only need the meta tag.
Every cool kid on the block makes a color-scheme toggle button thingy these days, even though your browser and operating system probably knows best. I'm not sure I agree it's needed, just like we don't have “Print this page” buttons anymore, but it's a fun exercise to play with.
So let's play.
When building web stuff, I always try to think in HTML first: what can HTML already do to make this happen? What can the server prepare? What would be the most performant solution for end users? (It's almost always HTML-related.) I already answered that question in the beginning: the color-scheme meta tag. That's what needs to change. And since I'm using server-side rendering (aargh, that term again!), the server should probably do this. So I made a form with two buttons:
<form method="post" action="/" hidden>
<button type="submit" name="toggle-color-scheme" value="light" title="Switch to light mode">☀️</button>
<button type="submit" name="toggle-color-scheme" value="dark" title="Switch to dark mode">🌙</button>
</form>
Hidden by default, since I don't know if the browser will support switching the color scheme. Next up, some CSS:
@supports (color-scheme: dark light) {
header > form {
display: block;
}
}
Some basic feature detection: if the browser supports color-scheme: dark light, show the form. I think only Safari 12.1 supports the meta tag and not the CSS property, but I can live with that. Those users won't see the toggle form, even though their browser supports switching. Oh well.
Another bit of CSS, since the form contains two buttons. Which of those two should be hidden. The browser knows. When in light mode, hide the “Switch to light mode” button. And when in dark mode…well, you get the idea:
@media (prefers-color-scheme: dark) {
button[name="toggle-color-scheme"][value="dark"] {
display: none;
}
}
@media (prefers-color-scheme: light) {
button[name="toggle-color-scheme"][value="light"] {
display: none;
}
}
That's the basic user interface done. Server-side I'm waiting for a form POST, set a session variable to the preferred color scheme (using a cookie), and redirect the user back to where they came from (with a 303). When spitting out the meta tag, I check if there's a preference in the session:
<meta name="color-scheme" content="{{ colorSchemeFromSession || 'dark light' }}">
This makes sure there's no FART, or Flash of inAccurate coloR Theme, either.
If there's a preference, I don't write out the @supports and media queries in CSS, and show a simpler form, with just one button, for the other color scheme. I played with an 'Auto' button, removing the session cookie, but I'm not convinced it's that helpful.
That's where I stopped yesterday. The basic view transition I had in place made this really smooth:
@media (prefers-reduced-motion: no-preference) {
@view-transition {
navigation: auto;
}
}
But today I added some client-side enhancements, in another <script type="module">. I basically wanted to play with something I learned from Marcin Wichary last week. I don't want to use npm or a boat load of dependencies, so I read the code, made myself a quick test on CodePen and ended up with this:
const form = document.querySelector('header form');
// When the form is submitted
form.addEventListener('submit', function(event) {
// Play a little sound
// (Will probably switch to the Web Audio API at some point, but for now this'll do)
const sound = new Audio('/_downloads/switch.mp3');
sound.play();
// Vibrate the users' device a little
if ('vibrate' in navigator) {
// Using the native browser API if that's supported
navigator.vibrate(100);
} else if ('switch' in document.createElement('input')) {
// Or using a Safari 'feature'
const switchInput = document.createElement('input');
switchInput.type = 'checkbox';
switchInput.id = 'switch';
switchInput.setAttribute('switch', '');
switchInput.hidden = true;
const switchLabel = document.createElement('label');
switchLabel.setAttribute('for', 'switch');
switchLabel.hidden = true;
document.body.appendChild(switchLabel);
document.body.appendChild(switchInput);
switchLabel.click();
}
// Let the server know about this as well, so the session cookie gets set for the next page view
// (This could be done with sendBeacon as well)
fetch(form.action, {method: 'POST', body: new URLSearchParams({'toggle-color-scheme': event.submitter.value})});
// Switch the color scheme and change the UI, providing feedback and feedforward for the user
const switchColorScheme = function() {
const meta = document.querySelector('meta[name="color-scheme"]');
event.submitter.classList.add('touched');
meta.setAttribute('content', event.submitter.value);
event.submitter.textContent = event.submitter.value == 'light' ? '🌙' : '☀️';
event.submitter.title = 'Switch to ' + (event.submitter.value == 'light' ? 'dark' : 'light') + ' mode';
event.submitter.value = event.submitter.value == 'light' ? 'dark' : 'light';
}
// Fire a view transition, if that's supported
if (document.startViewTransition) {
document.startViewTransition(switchColorScheme);
} else {
switchColorScheme();
}
// Prevent the default form submit, if everything went smooth
// If an error did occur, the form will just submit and the server will take over
event.preventDefault();
});
Learning by playing. I love it!