Dark Mode That Doesn't Flash
I cannot stand light mode for pretty much anything. Many of my hobbies, my full time job, my masters and my honorary research position all require screentime. Wearing anti-glare glasses helps, but light mode just burns my retinas actively. So, one of the first things I did when making my website an active project rather than a passive one, was adding dark mode. What the tutorials tend to skip for "adding dark mode" is you pick dark mode, you click a link, and the next page blasts you with a white screen for a quarter of a second before remembering your choice. In a dark room, at night, that flash is scorching, and it happens way too often, even on big-name sites. This post covers how this site's theme system avoids it. Part of the series on how this site is built.
Quick jargon guide
- FOUC / theme flash: "flash of unstyled content", the brief moment a page renders wrong (here: bright white) before correcting itself.
- localStorage: a small key-value store in the browser, per site, that survives across visits. Where the theme choice lives.
- data attribute: a custom label on an HTML element, like
data-theme="dark"on the page's root. CSS rules can target it. - Render-blocking: code the browser must run before it paints anything. Usually a dirty word; here it's the fix.
- iframe: a page embedded in another page. This site's comments are one, and they have opinions about their own colours that I had to navigate around.
The process
The site's theme is a single attribute on the root element: <html data-theme="dark"> or <html data-theme="light">. The stylesheet's light styles are the defaults, and dark mode is a set of override rules scoped to that attribute. These are the first two rules of this site's, verbatim:
body {
color: #252525;
background-color: #ffffff; }
[data-theme="dark"] body {
background-color: #1a1a1a;
color: #e2e2e2; }
The toggle button in the header flips the attribute, saves the choice to localStorage under a key (kr-theme), and everything restyles instantly, no reload. A brief cross-fade class softens the swap, and the browser's theme-color meta tag (which tints the address bar on phones) is updated to match. All of that is a 60-line JavaScript file.
The whole toggle (js/theme.js, 60 lines)
/**
* theme.js — Site-wide dark/light mode toggle.
*
* Responsibilities:
* - Read stored preference from localStorage on DOMContentLoaded
* - Default to dark mode when there is no stored preference
* - Apply data-theme="dark"|"light" to <html>
* - Keep the #theme-toggle button aria state in sync
* - Persist choice across page loads
*
* Flash-of-wrong-theme (FODT) prevention is handled by a tiny inline
* script injected into every page's <head> (see the kr-theme check).
*/
(function () {
var STORAGE_KEY = 'kr-theme';
var DEFAULT_THEME = 'dark';
function stored() {
try { return localStorage.getItem(STORAGE_KEY); } catch (e) { return null; }
}
function save(t) {
try { localStorage.setItem(STORAGE_KEY, t); } catch (e) {}
}
function apply(theme) {
document.documentElement.setAttribute('data-theme', theme);
var dark = theme === 'dark';
// Keep the browser UI (mobile address bar etc.) matching the page background.
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', dark ? '#1a1a1a' : '#ffffff');
var btn = document.getElementById('theme-toggle');
if (!btn) return;
btn.setAttribute('aria-pressed', String(dark));
btn.setAttribute('aria-label', dark ? 'Switch to light mode' : 'Switch to dark mode');
btn.setAttribute('title', dark ? 'Switch to light mode' : 'Switch to dark mode');
}
document.addEventListener('DOMContentLoaded', function () {
apply(stored() || DEFAULT_THEME);
// Delegated click — works even if button is injected after this script runs
var switchTimer = null;
document.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('#theme-toggle') : null;
if (!btn) return;
var next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
// Cross-fade the swap (CSS scopes transitions to .theme-switching)
document.documentElement.classList.add('theme-switching');
if (switchTimer) clearTimeout(switchTimer);
switchTimer = setTimeout(function () {
document.documentElement.classList.remove('theme-switching');
}, 350);
save(next);
apply(next);
});
});
}());
The hard part
The flash happens because of an ordering problem. The browser starts painting your page as soon as it has the HTML and CSS; your theme JavaScript, if it's a normal script loaded politely at the end, runs after that first paint. So the sequence in dark mode is: paint the default (light) page, run the script, discover the visitor wanted dark, restyle. Well, that's no good.
The fix is to cheat the ordering. The very first thing in this site's <head>, before the stylesheet, before anything, is this inline script (unsquashed here for reading; the shipped version is one line):
<script>
(function () {
var t;
try { t = localStorage.getItem('kr-theme'); } catch (e) {}
if (!t) t = 'dark';
document.documentElement.setAttribute('data-theme', t);
})();
</script>
Inline scripts in the head are render-blocking: the browser stops and runs them before painting a single pixel. Normally that's why performance guides tell you never to put scripts there. I broke the rules here because I made sure it's super efficient (it runs in well under a millisecond) and by doing the one job that must happen before paint: stamping the saved theme onto the root element to prevent harming the corneas of my few thousand readers (although the idea of my code having the power to burn eyeballs across the globe is kind of cool sounding). By the time the browser paints, the attribute is already correct, and there is nothing to flash. The try/catch is for browsers that block localStorage entirely; they get the default, which on this site is dark, on the fact that a light flash in a dark room is worse than a dark flash in a bright one.
The extras: images and iframes
Images with backgrounds don't theme themselves. My post about music listening data includes charts rendered as images, with axis labels and backgrounds that only look right on the right theme. The fix is low-tech and bulletproof: export the chart twice, once per theme, put both in the page, and let three CSS rules choose:
<img class="theme-img-light" src="chart-light.png" alt="Top artists chart">
<img class="theme-img-dark" src="chart-dark.png" alt="Top artists chart">
/* in the stylesheet */
.theme-img-dark { display: none; }
[data-theme="dark"] .theme-img-light { display: none; }
[data-theme="dark"] .theme-img-dark { display: block; }
Two images, one visible at a time, swapping instantly with the toggle. It costs a second download for the chart a visitor actually views, which for the occasional plot is nothing. (For photographs none of this applies; a photo is a photo on any background, I don't use frames in photos of dark or white)
Iframes are other people's pages. The comments section is an embedded giscus frame (the free-comments-via-GitHub-Discussions setup has a post of its own), and my CSS cannot reach inside it. giscus's answer is a message-passing API: when the toggle flips, the site posts a message into the frame telling it which stylesheet to use, and the comments swap themes in step with the page around them. Every embedded widget needs this conversation, which is one more argument for embedding as few widgets as possible.
// runs after the toggle is clicked; giscusTheme() returns the stylesheet for the new mode
var frame = document.querySelector('iframe.giscus-frame');
frame.contentWindow.postMessage(
{ giscus: { setConfig: { theme: giscusTheme() } } },
'https://giscus.app'
);
Respect the choice, remember the choice
The remaining design decision is what to do for a first-time visitor with no saved preference. The fashionable answer is to read the operating system's setting via a media query, and it's a good answer. This site instead defaults everyone to dark and makes the toggle obvious, partly because the photography looks better on dark, partly because the site had dark-first visuals long before it had a toggle, but mostly because I hate light theme and I force it upon everyone by default, because I care about your optical health, but you can opt into the pain if you prefer.
Common questions
Why not the pure-CSS route with prefers-color-scheme?
A media query alone gives you automatic theming with zero JavaScript, and if you don't want a manual toggle it's the simplest answer. The moment you add a toggle (and users do ask for one; OS setting and site preference differ), you need somewhere to store the override and something to apply it before paint, and you're back to the inline script.
Does the render-blocking script hurt performance?
Technically yes, in reality no. It's a few dozen bytes reading one localStorage key; the cost is microseconds. The rule against render-blocking scripts is about network fetches and heavy work, not about five lines of inline code doing the job that must precede painting.
Why one data attribute instead of a CSS class?
Near-total taste. An attribute reads as "this page has one theme state" while classes invite accumulating several. It also keeps theme selectors visually distinct from styling classes in the stylesheet, which after a few hundred override rules matters more than I expected.
How much CSS did dark mode actually take?
More than the tutorials imply: this site's dark theme is hundreds of override rules accumulated over time, because a template built light-first has hardcoded colours everywhere. Building theme-aware from day one (ideally with colour variables) is dramatically less work than retrofitting.