Hero image for "CSS Variables Won the Dark Mode Fight. JS Theme Objects Still Have a Job."

CSS Variables Won the Dark Mode Fight. JS Theme Objects Still Have a Job.


In 2023, GitHub's Primer team ran into a wall that should sound familiar to anyone maintaining a design system past a certain scale: as component counts grew on a page, their CSS-in-JS solution for theming started costing real money in page-load time and SSR performance, and style updates grew unpredictable as more components landed on the same screen (GitHub Blog). Their fix was a gradual migration to CSS Modules — static, native CSS, shipped once, read by the browser instead of recomputed by a runtime. It's not a dark-mode post specifically, but it's the clearest real-world data point I've seen for the actual argument underneath "CSS variables vs. JS theme objects": one of these approaches asks the browser to do the work, and the other asks JavaScript to do it, and at scale that distinction stops being academic.

The Case for Letting CSS Do the Switching

The architecture that's won among startups building dark mode today is close to boring: define tokens as CSS custom properties on :root, override them under an attribute selector like html[data-theme='dark'], and flip one attribute at runtime. Every themed rule is written once, referencing a var(), and the override cascades automatically — no duplicated rule blocks for .light and .dark, no full re-render of a component tree (Handoff). That's the mechanical reason teams reach for this over a JS theme object passed through Context: a Context update means React has to walk the tree and decide what changed, while a custom-property update is a single style recalculation the browser already knows how to do efficiently.

One practitioner writeup put rough numbers on the gap — a Context-driven theme switch in a mid-size app taking 40–80ms against under 5ms for a CSS custom-property flip (Empire UI). I'd treat that as directional rather than gospel — it's one team's benchmark, not an independently reproduced test — but it matches the shape of what Primer's own migration found: moving cost out of the JS runtime and into static CSS is consistently where the wins show up (GitHub Blog).

The Part Nobody Puts in the Dark Mode Tutorial

Here's where the pure-CSS-variables story gets less tidy. A developer who shipped a themeable credit-card component with what he thought was a clean custom-property API found three separate ways a consumer's override could silently get ignored, none of which threw an error (DEV Community). The one most relevant to dark-mode theming at scale: if your component declares its own value for a custom property, that declaration always beats an inherited one from an ancestor — even if your docs say "override from any ancestor." The other one that'll bite teams using Tailwind heavily: Tailwind v4 emits utilities inside @layer utilities, and in the cascade, layer order is resolved before specificity, so an unlayered plain CSS rule beats a Tailwind utility no matter how specific the utility looks. Neither bug produces a console warning. Both produce a support ticket that says "dark mode doesn't apply to this one component" and nothing else useful.

This is the tradeoff that gets glossed over in every "just use CSS variables" post: the approach wins on runtime cost, but it moves the failure mode from "obvious JS bug you can breakpoint" to "silent cascade precedence issue you have to reason about by hand." A JS theme object, by contrast, fails loudly — a missing key throws, a typo shows up in a type error. CSS variables fail by quietly resolving to the wrong thing and looking fine in a screenshot.

Where the JS Object Still Pulls Weight

The honest architecture most startups land on isn't pure CSS variables — it's CSS variables for the paint, with a JS layer for anything that needs runtime awareness of which theme is active: conditionally swapping a logo, picking a palette to hand to a third-party chart library, or managing a token pipeline that generates both the CSS custom-property file and a parallel JS object for Tailwind config extension (CoddyKit). The CSS variables carry the visual state; the JS object carries the decisions that need to be made about that state in code. Newer CSS features — color-mix(), light-dark(), sibling functions that let a component read its own position among siblings — are steadily shrinking the list of things you actually need JavaScript for in a themed component (zeroheight), but "shrinking" isn't "eliminated."

If you're deciding this architecture now, the question worth asking isn't which approach is more modern. It's whether anything downstream of your theme — a chart library, a logo swap, an analytics event — needs to know the theme in code, or whether it only needs to look different. Everything in the second bucket belongs in a custom property. Everything in the first bucket is going to need a JS object no matter how elegant your CSS variable layer gets, and pretending otherwise is how you end up debugging a cascade bug at 11pm instead of a type error at 3pm.