Dec 21

CSS light-dark()

A new CSS function that returns one of two color values based on the current color scheme.

By Mayank

light-dark() makes it easy to build websites that respect the user's preferred color scheme while also providing the ability to override the color scheme without code duplication. Be sure to check out the demo at the bottom! 

Before we dive into light-dark(), let's take a quick detour to understand color-scheme

Color scheme 

Browsers have a built-in "color scheme" mechanism, which can be specified using the color-scheme CSS property or the corresponding meta tag

The color-scheme can be set to either "light" or "dark" to indicate which mode the page supports. It can also be set to a space-separated "light dark" value, which indicates that the page supports both modes. 

Typically, you would set this on the html element so that it gets inherited by everything on the page.

html { 
color-scheme: light dark;
}

This is kinda like saying, "Hey browser, this page supports both light and dark modes, so go look at the user's preferences and decide which mode to actually use." 

The browser then uses the resolved color scheme value to determine the default colors for things like the page background, text color, scrollbars, form controls, and system colors

It's worth clarifying at this point that color-scheme is a completely separate concept from the prefers-color-scheme media query, which you might have used in the past to implement a dark mode. The media query is only concerned with looking at the user preferences. Even if you're using the media query, it should be paired with the color-scheme property so that scrollbars and form controls get more appropriate default styles. 

@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}

Everything we've learned about color-scheme thus far has been widely available across browsers for many years now. 

Now that we understand color-scheme, we can look at the new light dark() feature! 

Enter light-dark() 

You might have noticed that, up until now, color-scheme is somewhat of a one-way signal. Browsers can use color-scheme to adjust the default colors (and system colors), but authors cannot use it in their own code to adjust their custom styles. 

This is where light-dark() comes in. From the spec

System colors have the ability to react to the current used color scheme value. The light-dark() function exposes the same capability to authors. 

light-dark() is a CSS function that accepts two color values and returns one of them based on the used color-scheme. The following snippet will make all links be DeepPink in light mode and HotPink in dark mode.

:any-link {
color: light-dark(DeepPink, HotPink);
}

It's a clean and concise way of doing something that would have required multiple CSS rules and media queries in the past. 

The two colors don't need to be hardcoded; they can reference custom properties (aka "CSS variables"). And they can also be referenced by other custom properties (as we'll see later). 

:any-link {
color: light-dark(var(--accent-light), var(--accent-dark));
}

light-dark() has been newly available across modern browsers since early 2024. For older browsers, we can transpile our code using LightningCSS or a PostCSS plugin

Themed variables using light-dark() 

To get the most out of light-dark(), it is best used globally for the purpose of setting up themed custom properties. 

Let's start by defining three custom properties: --accent, --bg, and --fg.

html {
--accent: light-dark(DeepPink, HotPink);
--bg: light-dark(White, Black);
--fg: light-dark(Black, White);
}

Don't pay mind to the actual values in this example. It's probably not desirable to use the built-in named CSS colors. Usually, you'll want to agonize over picking the right colors. I'll leave that as an exercise for the reader. (If you need a little push, Open Props provides a few different color scales which can be a good starting point for your color system.) 

Once the custom properties are set up, we can use them everywhere in our CSS. Notice how we don't need to repeat the raw color values. Because these custom properties were set up using light-dark(), the whole page automatically works in both light and dark modes without needing to adjust anything at the component level. 

html {
accent-color: var(--accent);
background-color: var(--bg);
color: var(--fg);
}
*:focus-visible {
outline: 2px solid var(--accent);
}
:any-link {
color: var(--fg);
}
button {
color: var(--fg);
border: 1px solid var(--fg);
}
.surface {
background-color: var(--bg);
}
/* and so on... */

Overriding color-scheme 

So far, we've only seen how light-dark() can replace a media query. This is great, but we usually want more: the site should respect the user's preference by default, but the user should also be able to override the default color scheme. 

The key insight here is that light-dark() is tied to the color-scheme, rather than to user preference. While setting color-scheme: light dark will match the user preference, we can also override it manually. 

Let's say our website has a theme toggle, which sets a data-color-scheme attribute on the <html> element. 

/*
* Call this function from your theme toggle.
* @example
* setColorScheme("light");
* setColorScheme("dark");
*/


function setColorScheme(mode) {
document.documentElement.dataset.colorScheme = mode;
}

Now, we can use this data attribute as a CSS selector to change our color-scheme

html {
color-scheme: light dark;

&[data-color-scheme="light"] {
color-scheme: light;
}

&[data-color-scheme="dark"] {
color-scheme: dark;
}
}

Changing the color-scheme will automatically result in all instances of light-dark() returning the appropriate color value. In this instance, we are setting color-scheme on the same element (<html>) where our custom properties are defined. However, light-dark() also works for descendant elements because color-scheme is an inherited property. 

That's really all the code we need to implement light and dark modes. It respects the user preference (without a media query!) and can also be overridden. 

For comparison, implementing the same functionality without light-dark() would look something like the following. We would need to define the custom properties four different times, two of which are entirely duplicate copies of each other. 

/* without light-dark() */
html {
color-scheme: light dark;

@media (prefers-color-scheme: light) {
--accent: DeepPink;
--bg: White;
--fg: Black;
}

@media (prefers-color-scheme: dark) {
--accent: HotPink;
--bg: Black;
--fg: White;
}
}

html {
&[data-color-scheme="light"] {
color-scheme: light;

--accent: DeepPink;
--bg: White;
--fg: Black;
}

&[data-color-scheme="dark"] {
color-scheme: dark;

--accent: HotPink;
--bg: Black;
--fg: White;
}
}

Meanwhile, with light-dark(), we can keep all our custom properties neatly defined in a single place. 

/* with light-dark() */
html {
--accent: light-dark(DeepPink, HotPink);
--bg: light-dark(White, Black);
--fg: light-dark(Black, White);
}

The future is bright (and dark)! 

To the CodePen! 

Let's take everything we've learned about light-dark() and put it to use. 

This demo shows a tri-state toggle that works only by changing the document's color-scheme. All of the theme-related CSS is wrapped under @layer theme

See the CodePen.

Further reading 

If you're anything like me, you learn new topics by absorbing the same information from several different sources. So here are a few good links related to color-scheme and light-dark()

Mayank

Mayank

Mayank is a design engineer who cares deeply about accessibility and inclusivity. They enjoy working with new, revolutionary web tech, such as HTML and CSS. In their free time, they run their mouth on their personal blog.

Mayank selected For the Gworls for an honorary donation of $50

For the Gworls

For The Gworls is a Black trans-led collective that raises funds to directly help Black trans people pay for rent assistance, gender-affirming surgeries, and critical medical expenses.