Skip to main content
Theme

Apple Annie’s Weblog


What I've learned about CSS `color-scheme` and friends.

I worked on some theme updates for themes.lol last week. Along with updating my templates with social media <meta> tags, a site manifest, and favicons, I added a theme picker. Among the updates were <meta> tags for both theme-color and color-scheme. MDN states that color-scheme allows an element to indicate which color schemes it can comfortably be rendered in but that doesn't quite capture what it enables in combination with <system-color> and the even newer light-dark() CSS color function.

The set-up

After getting the theme picker in place on themes.lol I ported it over to weblog. I had removed a theme toggle that was previously here with plans to replace it with a more accessible version that includes a system default and user choice persistence. I ended up largely using the example found at The Perfect Theme Switch Component with alterations for each of my sites.

That blog post provides two scenarios for the component: a button (switch/toggle) or a picker (radios). With the button, as with the previous iteration of my theme switcher on weblog, you had to keep two states in check based on the "pressed" option the user chose and whatever the default of the system was and then make sure those states were in sync on :root and therefore the logic became more convoluted. The radio options, on the other hand, are far more straight-forward and don't require as much Javascript and so that is the configuration I chose to use on both sites.

On weblog I placed this at the top of the page; on themes.lol it is placed at the top of the <footer>. Both sites include "Skip to content" links and I added a corresponding "Theme picker" link to themes.lol to take the user to the theme picker in the footer. I also provided a "Back to top" link right next to the theme picker to take the user back up. This wasn't strictly necessary on weblog due to the picker being at the top of the page, but I went ahead and added the "Back to top" link due to some pages on weblog being fairly long. Both sites are also using scroll-behavior: smooth; to gracefully jump up and down the page without the sudden jank that can occur with "skip" links.

Alright, back to the subject at hand...

color-scheme

The CSS color-scheme property can take several values:

  • normal - no color schemes defined, default to the browser or OS setting
  • light - should be rendered in a light scheme
  • dark - should be rendered in a dark scheme
  • only - forbids overriding the scheme

To proceed with defining support for both a light and dark color scheme, you would add the following ruleset. In this order, the light scheme is the default and preferred scheme of the author, though styles are provided to support a dark scheme as well. If the order were dark first and light second, that would signal the opposite, that the author prefers a dark scheme.

:root {
  color-scheme: light dark;
  ...
  /* custom properties */
  ...
}

You can specify the color-scheme per element to say an element should only render in light or dark. For example, this is what is being done with the theme picker I added. When outside the theme automatically set by the system or browser, Javascript is used to apply a data attribute to :root. When [data-theme="light"] is applied we restrict color-scheme to light; alternatively when [data-theme="dark"] is applied, it is restricted to dark.

:root[data-theme="light"] {
  color-scheme: light;
  ...
  /* custom properties */
  ...
}

:root[data-theme="dark"] {
  color-scheme: dark;
  ...
  /* custom properties */
  ...
}

You would also define your CSS custom properties for colors per theme here as well. One additional ruleset is needed to restore a dark scheme set by the system or browser preferences due to specificity:

@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    color-scheme: dark;
    ...
    /* custom properties */
    ...
  }
}

Used in combination with the color-scheme <meta> tag, the browser can process the preferences of the document more quickly while it is still downloading and parsing a CSS file.

<meta name="color-scheme" content="light dark">

Let's see how these look in action on themes.lol:

Screenshot of the themes.lol footer with theme picker showing the use of the system/browser auto setting of the dark theme.
The themes.lol footer with theme picker showing the use of the system/browser auto setting of the dark theme.
Screenshot of the themes.lol footer with theme picker showing the use of the system/browser auto setting of the light theme.
The themes.lol footer with theme picker showing the use of the system/browser auto setting of the light theme.
Screenshot of the themes.lol footer with theme picker showing the use of the prefers setting of the dark theme.
The themes.lol footer with theme picker showing the use of the prefers setting of the dark theme.
Screenshot of the themes.lol footer with theme picker showing the use of the prefers setting of the light theme.
The themes.lol footer with theme picker showing the use of the prefers setting of the light theme.

Setting color-scheme also dictates how the browser will apply a new set of colors called system colors. Let's go over those next.

<system-color>

System colors define a set of colors that a browser will use as the default color choices...for the different parts of a web page. The MDN page for <system-color> lists the current keyword colors and a handful of deprecated keyword colors. Most of these colors control the application of text on a canvas and the resulting values correspond with the current mode of color-scheme. In this respect, color-scheme is far more powerful than simply defining what support for light and dark schemes a web page has.

[color-scheme] changes the default text and background colors of the page to match the current system appearance. Standard form controls, scroll bars, and other named system colors also change their look automatically.

Themes.lol styles have sample form controls on the specimen pages to show the styling for each. As of right now, there are only a handful of elements. Part of the elements are styled with custom properties applicable to each style, but many parts of the controls rely solely on the magic that color-scheme enables with system colors.

Screenshot of the themes.lol style Cordial specimen page showing form controls in the dark theme.
The themes.lol style Cordial specimen page showing form controls in the dark theme.
Screenshot of the themes.lol style Cordial specimen page showing form controls in the light theme.
The themes.lol style Cordial specimen page showing form controls in the light theme.

These two examples show the Cordial style. The controls that support the property are using accent-color to apply a color to the controls for a custom look that goes with the theme. You can see this in the color that appears for the checked states of radios and checkboxes and the selected portion of the range input. But the non-checked states of radios and checkboxes and the background and border of the text input and select element are using system colors for their light and dark colors without me needing to add any additional CSS, simply by providing values for color-scheme support.

The system UI and browser controls will adapt their appearance based on the selected color mode. The <system-color> values tap into this and also adapt, ensuring proper contrast between text and canvas.

light-dark()

We have one more up-and-comer friend who will soon join the party: the CSS light-dark() color function. When you define color-scheme on :root the light-dark() function allows you to author your light and dark properties within a single declaration rather than needing to rely on prefers-color-scheme media queries.

Users are able to indicate their color-scheme preference through their operating system settings (e.g. light or dark mode) or their user agent settings. The light-dark() function enables providing two color values where any <color> value is accepted. The light-dark() CSS color function returns the first value if the user's preference is set to light or if no preference is set and the second value if the user's preference is set to dark.

The light-dark() function can be used to supply both the light and dark colors for a property such as color for text or background-color for the canvas. Since you can use it anywhere a color value is accepted, it can also be used as the value for a custom property.

Up until now, reacting to the used color-scheme value was something that was reserved for the system colors. Thanks to light-dark(), specified in CSS Color Module Level 5, you now also have the same capability.

Using light-dark() on themes.lol would allow me to replace the following:

:root {
  color-scheme: light dark;

  ...

  /* No support for light-dark() */
  --background:  var(--primary1-p4);
  --foreground:  var(--primary2-m0);
  --link:        var(--primary2);
  --accent:      var(--primary1-m4);
  --line:        var(--primary1-m2);
  --highlight:   var(--primary1);
}

/* Light theme */
:root[data-theme="light"] {
  color-scheme: light;

  --background:  var(--primary1-p4);
  --foreground:  var(--primary2-m0);
  --link:        var(--primary2);
  --accent:      var(--primary1-m4);
  --line:        var(--primary1-m2);
  --highlight:   var(--primary1);
}

/* Dark theme override */
:root[data-theme="dark"] {
  color-scheme: dark;

  --background: var(--primary2-p0);
  --foreground: var(--primary1-p4);
  --link:       var(--primary1);
  --accent:     var(--primary2-p7);
  --line:       var(--primary2-p2);
  --highlight:  var(--primary2);
}

/* Dark theme (system preference) */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    color-scheme: dark;

    --background: var(--primary2-p0);
    --foreground: var(--primary1-p4);
    --link:       var(--primary1);
    --accent:     var(--primary2-p7);
    --line:       var(--primary2-p2);
    --highlight:  var(--primary2);
  }
}

with:

:root {
  color-scheme: light dark;

  ...
  
  /* Support for light-dark() */
  --background: light-dark(var(--primary1-p4), var(--primary2-p0));
  --foreground: light-dark(var(--primary2-m0), var(--primary1-p4));
  --link:       light-dark(var(--primary2), var(--primary1));
  --accent:     light-dark(var(--primary1-m4), var(--primary2-p7));
  --line:       light-dark(var(--primary1-m2), var(--primary2-p2));
  --highlight:  light-dark(var(--primary1), var(--primary2));
}

/* Light theme */
:root[data-theme="light"] {
  color-scheme: light;
}

/* Dark theme override */
:root[data-theme="dark"] {
  color-scheme: dark;
}

/* Dark theme (system preference) */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    color-scheme: dark;
  }
}

The light-dark() function takes the place of needing to repeat changing color values within each condition or selector: As an added bonus, it's possible to force a certain subtree of the DOM to use only light or dark mode by setting color-scheme to either dark or light.

Support

I have a test page on weblog that I use for prototyping new components, testing Javascript, or even when I'm trying out some content to see how it interacts with other content. It is just a jumble of stuff that gets added, deleted, etc. Right now there is an <iframe> embed for my Raindrop.io Blogroll collection.

While I was incorporating the theme picker into weblog on that page, I noticed that switching the picker between auto, light, and dark, resulted in the <iframe> also switching the color scheme! I do my main development work in Firefox Developer Edition, which is one release ahead of the main Firefox browser, but it does appear Firefox is the only one handling this correctly right now. I tested in Safari on MacOS and the behavior was not repeated.

After I noticed the inconsistency between Firefox and Safari, I looked into whether there were any standards for color-scheme to pass through to another document in an <iframe>. I pinged an acquaintance on Mastodon who I know used to frequent the CSS Working Group, if not currently, in the past, and discovered a thread on the issue. The acquaintance provided me with the recommendation:

For all elements, the user agent must match the following to the used color scheme:

  • the default colors of scrollbars and other interaction UI
  • the default colors of form controls and other "specially-rendered" elements
  • the default colors of other browser-provided UI, such as "spellcheck" underlines

On the root element, the used color scheme additionally must affect the surface color of the canvas, and the viewport's scrollbars.

In order to preserve expected color contrasts, in the case of embedded documents typically rendered over a transparent canvas (such as provided via an HTML <iframe> element), if the used color scheme of the element and the used color scheme of the embedded document's root element do not match, then the UA must use an opaque canvas of the Canvas color appropriate to the embedded document's used color scheme instead of a transparent canvas. This rule does not apply to documents embedded via elements intended for graphics (such as <img> elements embedding an SVG document).

Current browser support for evergreen browsers among the various tools related to color-scheme are not entirely consistent right now, but will get you pretty far. color-scheme has the farthest reaching support, followed by <system-color>, and light-dark() comes in last.


Post info

Additional resources and links in this post:

POSSE copies:

Share this post on social media.

Discuss on Mastodon


Back to top

Featured post

Recent posts

Search posts

Works in Progress