How I Built CSS's light-dark() in TypeScript
Leveraging Modern JavaScript to Mimic CSS Behavior Without Headaches
This weekend I learned about CSS's light-dark()
function, which automatically adapts colors to user preferences without JavaScript.
Naturally, I wondered if we can recreate this behavior in TypeScript.
The challenge was deceptively complex because I wanted a solution that could handle live updates while staying memory-safe.
In this article, I'll show you how I achieved the desired behavior in tokens that maintain string compatibility while responding to system changes automatically.
Why It Matters
Design tokens are format-agnostic values that create a shared visual language between designers and engineers. Being format-agnostic means they transcend specific implementations like CSS or TypeScript.
Keeping the underlying logic consistent across these formats gives us three key advantages:
Truthiness
Tokens reflect actual design decisions, not CSS/TypeScript tokens or Figma variables, which eliminates interpretation gaps.Consistency
No drift/errata between systems or hex value mismatches in different files.Resilience
Update a token in one place and it propagates everywhere.
These three cover the critical needs: accuracy, reliability, and adaptability.
The Basic Implementation
We could easily implement this in TypeScript just by checking the (prefers-color-scheme: dark)
query:
export const lightDark = (light: string, dark: string) => {
const query = globalThis.matchMedia('(prefers-color-scheme: dark)');
const isDark = query?.matches || false;
return isDark ? dark : light;
};
The problem is that we’ll be passing around these values in the theme
object.
The function above returns a value once, which means we would need to invoke it whenever we want to get the current value of that token.
More Advanced Requirements
In CSS, our theme tokens are defined like this:
--app-background-color: light-dark(var(--slate-100), var(--slate-900));
--text-color: light-dark(var(--slate-900), var(--slate-100));
That means that in TypeScript, our theme object needs to be instantiated likewise:
export interface Theme {
readonly appBackgroundColor: string;
readonly textColor: string;
}
export const THEME = Object.freeze<Theme>({
appBackgroundColor: lightDark(SLATE[100], SLATE[900]),
textColor: lightDark(SLATE[900], SLATE[100]),
});
This adds the following requirements to our implementation:
Values returned by
lightDark()
must be auto-updated when the system theme is changed.This solution must not result in memory leaks. Basically, if we use event listeners we need to also use something like an
AbortController
to remove it if/when ourtheme
object is cleaned up by garbage collection.Performance should be non-blocking. We need to keep in mind our core web vitals; a bad approach could degrade INP and CLS scores.
Syntax and typing should be straightforward enough that this solution results in an intuitive developer experience.
In order for values to be auto-updated while maintaining the best syntax and typing, values returned by lightDark() must be string-like.
What this means is that the value must behave like a string when we pass it around, especially when we interpolate it in template strings (which is important for CSS-in-JS).
However, since everything in JavaScript is an object, the value can also have a prototype which is a subtype of String and/or use custom properties/methods that regular strings don’t have.
Possible Approaches
This table summarizes why there are really only two approaches worth considering. An ⨉ is a deal-breaker basically.
I ended up going with Per-Value Getters but I’ll give a brief overview of each approach with pros and cons below.
Proxy
Uses a JavaScript Proxy
to create a theme object that dynamically resolves values when accessed. The proxy intercepts property lookups and returns either the light or dark value based on the current system theme.
What Is A
Proxy
Object?
Think of it like a wrapper object that can handle reactive side-effects. Example:SessionData
withProxy
, where theProxy
syncs to the session cookie whenever a property of theSessionData
object is updated.
Pros:
✅ Single event listener for entire theme
✅ Optimal memory efficiency
✅ No per-access computation
Cons:
⚠️ Requires wrapper function (createTheme
)
⚠️ Proxy behavior can be confusing to debug
⚠️ Less granular garbage collection
Per-Value Getters
Each value created by lightDark()
checks the current state of the dark mode media query.
We must be cautious with this approach because repeatedly checking the media query on every property access might be inefficient if done very frequently.
In practice, the media query check is very fast.
Pros:
✅ Plain object syntax (no wrappers)
✅ Direct access to light/dark values
✅ Framework-agnostic implementation
Cons:
⚠️ Per-access computation cost
⚠️ Complex memory management
Value Objects
Explicit object-oriented approach where lightDark()
values are object literals used by a Proxy
to toggle between modes.
Pros:
✅ Optimal control and performance
✅ No hidden listeners
✅ Easy debugging
Cons:
❌ No automatic theme updates
❌ Doesn't match CSS behavior
⚠️ Verbose usage patterns
⚠️ Manual subscription management
In a simple application this could be okay, but for me this was a no-go right off the bat since I was going for 1-1 CSS behavior and return values must be string-like.
Event-Based
Traditional observer pattern with manual subscription and update propagation. Maintains a centralized store that notifies subscribers when theme changes.
Pros:
✅ Explicit control flow
✅ Framework-agnostic core
✅ Predictable updates
Cons:
❌ Manual subscription management
❌ No string-like behavior
⚠️ Memory leak potential
⚠️ Update propagation complexity
This approach might be okay in an application already using similar patterns, like Redux or Signals. But I wanted to do this with as few peer dependencies as possible, so this approach was out.
Why Not Use A Proxy Object?
When I first started thinking about my solution, I initially wanted to wrap the theme in a Proxy
object to check the dark-mode query anytime we accessed a property.
The main blocker here was that the Proxy
knows the theme
’s keys and values but doesn’t know how those values were instantiated.
Not every theme
token uses lightDark()
, and we would need a way to flag token values under the hood to know which ones do and which ones don’t.
This is where the “string-like” requirement comes from. Early on, I was thinking about using a custom symbol that the Proxy
could look for to determine if it should check the dark-mode query.
As I played around with it, I realized that this approach was over-complicating things a bit. Instead of creating custom symbols, I realized I could streamline things with existing symbols like Symbol.toPrimitive
.
Proxy
objects themselves can cause problems with reflection and typing because they’re not the actual objects we want, so I’m glad I was able to find a workaround.
Without Further Ado
The final implementation leverages JavaScript’s new(-ish) FinalizationRegistry
(ES2021) for cleanup as well as clever primitive wrapping to create string-like theme values that auto-update when users change their system preferences.
The Code
'use client';
let query: MediaQueryList | undefined;
let isDark = false;
let refs = 0;
const handleThemeChange = ({ matches }: MediaQueryListEvent) => {
isDark = matches;
};
const colors = new FinalizationRegistry(() => {
if (--refs <= 0 && query) {
query.removeEventListener('change', handleThemeChange);
query = undefined;
}
});
export const lightDark = (light: string, dark: string) => {
if (!query) {
query = globalThis.matchMedia('(prefers-color-scheme: dark)');
isDark = darkModeQuery?.matches || false;
query?.addEventListener('change', handleThemeChange);
}
if (query) {
const color = Object.assign('', {
valueOf: () => isDark ? dark : light,
toString: () => color.valueOf(),
[Symbol.toPrimitive]: (hint: string) =>
hint === 'number'
? Number(color.valueOf())
: color.valueOf(),
});
colors.register(color, null);
refs++;
return color;
}
return light;
};
The Demo
How It Works
The first
lightDark()
call initializes a single dark mode media query listener, avoiding duplicate listeners across multiple calls.Object.assign('', {...})
fulfills our “string-like” requirement with custom versions of built-in string methods:valueOf()
checks the current query state and returns the right valuetoString()
ensures string coercion in template strings[Symbol.toPrimitive]
adds even better coercion for all primitives
FinalizationRegistry
tracks when colors are garbage collected. When the last color is cleaned up, it removes the listener for the dark mode query to prevent memory leaks.The
refs
counter ensures the media listener remains active while any theme object exists. Whenrefs
hits zero, resources clean up automatically.If the function is called server-side, only the light-mode color is returned since the dark mode query will be
undefined
.
Updating The Theme
User changes system theme → browser fires a
change
event on the queryhandleThemeChange()
updatesisDark
state accordinglySubsequent
valueOf()
calls return new theme value, so all theme tokens automatically reflect the new state
Wrapping Up
I’m honestly pretty amazed at how I was able to do this.
While this solution uses a more modern memory management API, I have a feeling it could be tweaked to use WeakMaps or WeakSets for better compatibility if needed.
The result is theme values that simply work, adapting to user preferences while maintaining perfect interoperability with existing styling paradigms.
Up Next
✍️ (In Progress) How Design Tokens Map To Sensory Perception (previously: Why We Use Musical Scales In Design Systems)
✍️ (In Progress) Building Software As A Pit Of Success
🏗️ (In Progress) Accessible SVG Icons The Right Way
Any thoughts on what else you’d like to see? Leave a comment or hop into chat and let me know!