Two weeks ago I added dark mode to this website. It was late one night and I was revisiting an article and my eyes were tired, so that was that. It was based solely on system dark mode settings, and I started using some more nice, modern CSS features like light-dark() to tie it all together.

But some people want dark mode for their desktop and light mode for their websites or the opposite, and sometimes I’m like that too. So I wanted to do it.
But I want this website to be peculiar. Like: I obsessively optimize it and intentionally use zero JavaScript on a basic pageload, though YouTube and Bandcamp embeds often bring in some junk. But even then, I optimize my YouTube embeds with lite-youtube-embed.
So: I wanted theme switching, but zero JavaScript involved in setting it or reading it. Here’s what I’m currently deploying:
Form
The UI is a form: you can see it in the top right corner, those three circles. I don’t love that position and might tinker with it. But it’s just a form, or three of them: zero JavaScript, just buttons. Browsers are great at forms. We’re posting to /mode.css, the same location as the CSS.
<form class='mode-container' action="/mode.css" method="POST">
<button type="submit"
title='Auto theme'
class='mode auto' name="scheme" value="auto"
>Auto</button>
<button type="submit" name="scheme" value="light" class="mode light"
title='Light theme'
style='background-color: var(--mode-light)'
>Light</button>
<button type="submit" name="scheme" value="dark" class='mode dark'
title='Dark theme'
style='background-color: var(--mode-dark)'
>Dark</button>
</form>
Function
That /mode.css endpoint is powered by a Netlify function, which uses Deno.
When it’s used as the form action, this receives a POST request, figures out which color scheme someone wants, and sends them back to the page that sent them but with a new cookie set for their browser. It’s all same-domain requests, so your browser sends cookies even with CSS requests, and thankfully it also sends full referrer URLs.
And then when /mode.css is accessed by a GET request, it reads that cookie and sets a different color-scheme for the site. If this whole system fails, you just get some non-functional buttons but the rest of the CSS loads fine because it’s all static.
export default async (req) => {
const headers = {
'Content-Type': 'text/css'
};
const cacheHeaders = {
'Netlify-Vary': 'cookie=scheme',
'Netlify-CDN-Cache-Control': 'public, max-age=3600, stale-while-revalidate=120',
'Cache-Control': 'public, max-age=60'
};
if (req.method === 'GET') {
const cookie = req.headers.get('Cookie');
if (cookie.includes('colorscheme=light')) {
return new Response(`:root { color-scheme: light; }`, { headers, ...cacheHeaders });
}
if (cookie.includes('colorscheme=dark')) {
return new Response(`:root { color-scheme: dark; }`, { headers, ...cacheHeaders });
}
return new Response(`:root { color-scheme: light dark; }`, { headers, ...cacheHeaders });
}
if (req.method === 'POST') {
const scheme = b.get('scheme');
const back = `<meta http-equiv="refresh" content="0; url=${req.headers.get('Referer') || 'https://macwright.com/'}">`
const b = await req.formData();
if (scheme === 'light' || scheme === 'dark') {
return new Response(back, {
headers: {
'Content-Type': 'text/html',
'Set-Cookie': `colorscheme=${scheme}; SameSite=Lax;`
}
})
} else {
return new Response(back, {
headers: {
'Content-Type': 'text/html',
'Set-Cookie': 'colorscheme=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT'
}
})
}
}
return new Response('Method not supported');
}
export const config = { path: "/mode.css" };
Note that it might be tempting to use a 304 response or to otherwise just ‘navigate back’ to the sending page, but browsers will treat that differently than a http-equiv=”refresh” tag: just like hitting the back button in your browser, that isn’t guaranteed to force a reload, so it isn’t going to get a fresh copy of the /mode.css file with correct colors.
TODO
That’s it: I just use light-dark for a lot of CSS properties to flip things in light and dark modes.
I think it works pretty well! I’ll keep an eye on the performance of /mode.css though, because it blocks the render of the full page. So that needs to be lightning-fast.
The buttons are fairly accessible: they’re normal buttons, keyboard-accessible, with good focus states, but I wish they had visible labels. It’s hard to balance the concern of wanting them to be clear, but not wanting them to steal attention from the rest of the page. They might eventually land in the navigation menu instead: a select box would be an elegant UI, but selects can’t auto-submit themselves without JavaScript.
I also don’t supply the meta color scheme tag yet, because that would require server logic built into the HTML of this website, which is something I’m not willing to do at this point. If I switch to something like a Caddy setup and self-host, that would become feasible.
I’m also in the process of figuring out how caching should work for this: ideally browsers are able to cache /mode.css as much as they’d like, but issuing a POST to the endpoint essentially clears the cache. Caching in practice is always a challenge!
Honestly I think this has 50% odds of surviving for a year. It might be useful and quiet, or it might make my Netlify usage go up and cause all sorts of trouble. We’ll see how it goes!