Y2K CSS: Making a Library Follow a Theme Toggle It Doesn't Know About
- Published
- Read
- 3 min
- By
- creationwasteland
RelatedNanoscript: I Built a Fake 90s Computer to Run It in the Browser
This site is built on y2k-css, a small CSS library I wrote for dithers, hard shadows and bevels. It handles dark mode the simple way: a @media (prefers-color-scheme: dark) block swaps the tiles and shadows when the operating system is dark. That was fine until I wanted a light/dark toggle on the site. A toggle needs a class or an attribute on the page, and a media query cannot see one.
The proper fix is to give y2k-css a class-based theme, and that is on the list for the next version. In the meantime I wanted the toggle now, and I had the same problem with three stylesheets I do not own: the GitHub markdown CSS that renders my READMEs, and the highlight.js and Prism themes that colour code blocks. All three pick light or dark with the same media query.
The idea
Every one of those files has the same shape: some rules at the top level, and some rules inside a @media (prefers-color-scheme: dark) block. I do not need the browser to evaluate that block. I need those rules to apply when the page says <html data-theme="dark">. So at build time I rewrite the file: take every rule out of the media block and prefix its selectors with :root[data-theme="dark"].
/* before */
@media (prefers-color-scheme: dark) {
.dither-black-alpha-3 { background-image: url("...white tile..."); }
}
/* after */
:root[data-theme="dark"] .dither-black-alpha-3 { background-image: url("...white tile..."); }
The prefixed rule is more specific than the base rule, so it wins when the attribute is present and does nothing when it is not. Nothing in the library changes. The site imports the rewritten copy instead of the original.
The script
PostCSS was already in the project because of Tailwind, so the script is short. It parses the file, walks the media at-rules, prefixes the selectors of everything inside, and replaces the at-rule with its children.
function unwrapSchemeMedia(root) {
root.walkAtRules("media", (at) => {
const m = at.params.match(/prefers-color-scheme:\s*(dark|light)/);
if (!m) return;
prefixContainer(at, m[1]);
at.replaceWith(...at.nodes);
});
}
function prefixSelector(sel, theme) {
const attr = `[data-theme="${theme}"]`;
const s = sel.trim();
if (s === ":root" || s === "html") return `${s}${attr}`;
if (s.startsWith(":root") || s.startsWith("html")) {
return s.replace(/^(:root|html)/, `$1${attr}`);
}
return `:root${attr} ${s}`;
}
Two details caught me. Selector lists have to be split on commas at the top level only, or something like :is(a, b) gets cut in half. And a rule that targets :root or html itself needs the attribute on that token, not a descendant combinator in front of it, or it never matches anything.
The highlight.js and Prism themes are a slightly different case. Each theme is a whole file for one scheme, so the script prefixes every rule in the light file with :root[data-theme="light"] and every rule in the dark file with the dark attribute, and writes both into one stylesheet.
The script runs before next dev and next build, so a library upgrade is picked up automatically, and the output is committed so a fresh clone builds without running it.
Setting the attribute before anything paints
The attribute has to be on the page before the first paint, or you get a flash of the wrong theme. React is too late for that. An inline script at the top of <body> runs first:
(function () {
var h = document.documentElement;
try {
var s = localStorage.getItem("cw:theme");
var t = (s === "light" || s === "dark")
? s
: (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
h.setAttribute("data-theme", t);
h.style.colorScheme = t;
} catch (e) {
h.setAttribute("data-theme", "light");
}
})();
"Auto" means there is no stored value, so the operating system decides, and a small listener keeps following it if the setting changes while the tab is open. The toggle itself is a button that cycles Auto, Light, Dark and writes both the attribute and localStorage. My own stylesheet keys its dark values off the same attribute, and Tailwind is configured with darkMode: ["selector", '[data-theme="dark"]'] so any dark: utility follows it too.
What I would do differently
This is a workaround, and I know it. The clean version is for y2k-css to ship both: media-query defaults for anyone who drops it into a page, and attribute-scoped rules for anyone who wants a toggle. That is a small change to the library's build, and it is next. The nice part of doing it this way first is that the rewrite script is exactly the code the library build will need.

Like what I said? Hate what I said? Tell me what you think: kameron@creation-wasteland.com