una.im | Modern CSS theming with light-dark(), contrast-color(), and style queries
Modern CSS theming with light-dark(), contrast-color(), and style queries<br>Published on<br>June 22, 2026
{tags.map((tag: string) => {<br>return ({tag}<br>})}
} --><br>I’ve been playing with a combination of new CSS features that, together, form a really nice dynamic theming system. This technique creates themed components with shadows in light mode that swap out to glowing borders in dark mode, and text that’s always readable against its dynamic background color. All in CSS. Available in all modern browsers as of May 2026 🥳.
Here’s what we’re combining:
@property: to register a custom property value with a meaningful syntax.
light-dark(): resolve values based on the computed color-scheme (aligned to user preference queries)
contrast-color(): auto-pick black or white text for any background based on the WCAG contrast algorithm
@container style(): style queries branch into custom color palettes based on what contrast-color() resolves to
And an optional @function to make this all a bit cleaner to use (though it does limit the browser support).
⬇️ Let’s get into it.
Step 1: light-dark() as the foundation #
light-dark() is essentially an inline alternative to writing user-preference media queries based on theming. This function takes two color values and returns the first in a light color-scheme, the second in dark. It reads from the element’s computed color-scheme, which means it responds to both prefers-color-scheme and any explicit color-scheme property set in CSS or via JavaScript.
html {<br>color-scheme: light dark;
body {<br>/* Set a --bg custom property using light-dark() */<br>--bg: light-dark(lightblue, black);<br>background: var(--bg);<br>One thing worth highlighting: light-dark() responds to the computed color-scheme, not to prefers-color-scheme media queries. Setting color-scheme: light dark on the root tells the browser to respect the user’s OS preference, but you can override it by setting color-scheme: dark (or light) explicitly. This means you can override it at any level:
/* Respect OS preference by default */<br>html {<br>color-scheme: light dark;
/* Force a section to dark regardless of OS preference */<br>.dark-section {<br>color-scheme: dark;<br>Every light-dark() call inside .dark-section — including those inside @function --elevation() — will resolve to its dark value. This is more powerful than media queries because it’s inherited and composable.
Step 2: Swap the elevation mechanism #
In light themes, cards look elevated through shadows. But in dark themes, shadows disappear into the void… so you need a different elevation mechanism. In this case, we’ll create a subtle neon-ish border glow against dark surfaces.
light-dark() only accepts values, so you can’t toggle entire shadow definitions with it. Instead, you can use light-dark() per shadow layer’s color, making unwanted layers transparent in the wrong mode:
.card {<br>box-shadow:<br>/* Subtle shadow--visible in light, transparent in dark */<br>0 1px 2px light-dark(lightgray, transparent),<br>0 4px 12px light-dark(lightgray, transparent),<br>0 12px 32px light-dark(lightgray, transparent),
/* Glowy border--transparent in light, visible in dark */<br>inset 0 0 0 1px light-dark(transparent, oklch(from var(--bg)<br>calc(l + 0.5) c h / 0.5)),<br>0 0 16px light-dark(transparent, oklch(from var(--bg)<br>calc(l + 0.5) c h / 0.3)),<br>0 0 3px light-dark(transparent, oklch(from var(--bg)<br>calc(l + 0.5) c h / 0.5));<br>All 6 shadow layers are technically always present, but 3 are transparent at any given time.
Additionally, for the glowy border, relative color syntax (oklch(from var(--bg) ...)) derives the glow colors from the card’s background, so the elevation always feels tinted to the card.
That works, but it’s a lot to repeat on every element that needs elevation. @function lets us clean this up behind-the-scenes:
@function --elevation(--color) {<br>result:<br>0 1px 2px light-dark(lightgray, transparent),<br>0 4px 12px light-dark(lightgray, transparent),<br>0 12px 32px light-dark(lightgray, transparent),<br>inset 0 0 0 1px light-dark(transparent, oklch(from var(--bg) calc(l + 0.5) c h / 0.5)),<br>0 0 16px light-dark(transparent, oklch(from var(--bg) calc(l + 0.5) c h / 0.3)),<br>0 0 3px light-dark(transparent, oklch(from var(--bg) calc(l + 0.5) c h / 0.5));
Then, you can just call the custom function, like this:
.card {<br>box-shadow: --elevation(var(--bg));
Warning: While cleaner, with @function, this technique becomes more limited in terms of browser support.
Step 3: Automatic contrast with contrast-color() #
I wrote about contrast-color() a few months ago. It’s a newly-available function takes any color and returns either black or white, whichever has higher contrast against that input. Here, we use it to ensure card text is always readable regardless of the brand color:
.card {<br>--bg: var(--brand-color);<br>background: var(--bg);<br>color: contrast-color(var(--bg));<br>That’s it. One line for accessible text. But black and white can...