Honor prefers-color-scheme in the CSS Paint API with Custom Properties

One of the coolest things I’ve been messing with in the last couple years is the CSS Paint API. I love it. I did a talk on it, and made a little gallery of my own paint worklets. The other cool thing is the prefers-color-scheme media query and how you can use it to adapt to a user’s preference for light or dark modes.

Recently, I found out that I can combine both of these really cool things with CSS custom properties in such a way that a paint worklet’s appearance can be tailored to fit the user’s preferred color scheme!

Setting the stage

I’ve been overdue for a website overhaul, and I decided to go with a Final Fantasy II theme. My first order of business was to make a paint worklet that was a randomly generated Final Fantasy-style landscape I named overworld.js:

An 8-bit illustration landscape of a forest with scattered pine trees and a jagged river running through the green land.
A randomly generated 8-bit style landscape, made possible by the CSS Paint API!

It could use a bit more dressing up—and that’s certainly on the agenda—but this here is a damn good start!

After I finished the paint worklet, I went on to work on other parts of the website, such as a theme switcher for light and dark modes. It was then that I realized that the paint worklet wasn’t adapting to these preferences. This might normally be a huge pain, but with CSS custom properties, I realized I could adapt the paint worklet’s rendering logic to a user’s preferred color scheme with relative ease!

Setting up the custom properties for the paint worklet

The state of CSS these days is pretty dope, and CSS custom properties are one such example of aforementioned dopeness. To make sure both the Paint API and custom properties features are supported, you do a little feature check like this:

const paintAPISupported = "registerProperty" in window.CSS && "paintWorklet" in window.CSS`

The first step is to define your custom properties, which involves the CSS.registerProperty method. That looks something like this:

CSS.registerProperty({ name, // The name of the property syntax, // The syntax (e.g., <number>, <color>, etc.) inherits, // Whether the value can be inherited by other properties initialValue // The default value
});

Custom properties are the best part of using the Paint API, as these values are specified in CSS, but readable in the paint worklet context. This gives developers a super convenient way to control how a paint worklet is rendered—entirely in CSS.

For the overworld.js paint worklet, the custom properties are used to define the colors for various parts of the randomly generated landscape—the grass and trees, the river, the river banks, and so on. Those color defaults are for the light mode color scheme.

The way I register these properties is to set up everything in an object that I call with Object.entries and then loop over the entries. In the case of my overworld.js paint worklet, that looked like this:

// Specify the paint worklet's custom properties
const properties = { "--overworld-grass-green-color": { syntax: "<color>", initialValue: "#58ab1d" }, "--overworld-dark-rock-color": { syntax: "<color>", initialValue: "#a15d14" }, "--overworld-light-rock-color": { syntax: "<color>", initialValue: "#eba640" }, "--overworld-river-blue-color": { syntax: "<color>", initialValue: "#75b9fd" }, "--overworld-light-river-blue-color": { syntax: "<color>", initialValue: "#c8e3fe" }
}; // Register the properties
Object.entries(properties).forEach(([name, { syntax, initialValue }]) => { CSS.registerProperty({ name, syntax, inherits: false, initialValue });
}); // Register the paint worklet
CSS.paintWorklet.addModule("/worklets/overworld.js");

Because every property sets an initial value, you don’t have to specify any custom properties when you call the paint worklet later. However, because the default values for these properties can be overridden, they can be adjusted when users express a preference for a color scheme.

Adapting to a user’s preferred color scheme

The website refresh I’m working on has a settings menu that’s accessible from the site’s main navigation. From there, users can adjust a number of preferences, including their preferred color scheme:

The color scheme setting cycles through three options:

  • System
  • Light
  • Dark

“System” defaults to whatever the user has specified in their operating system’s settings. The last two options override the user’s operating system-level setting by setting a light or dark class on the <html> element, but in the absence of an explicit, the “System” setting relies on whatever is specified in the prefers-color-scheme media queries.

The hinge for this override depends on CSS variables:

/* Kicks in if the user's site-level setting is dark mode */
html.dark { /* (I'm so good at naming colors) */ --pink: #cb86fc; --firion-red: #bb4135; --firion-blue: #5357fb; --grass-green: #3a6b1a; --light-rock: #ce9141; --dark-rock: #784517; --river-blue: #69a3dc; --light-river-blue: #b1c7dd; --menu-blue: #1c1f82; --black: #000; --white: #dedede; --true-black: #000; --grey: #959595;
} /* Kicks in if the user's system setting is dark mode */
@media screen and (prefers-color-scheme: dark) { html { --pink: #cb86fc; --firion-red: #bb4135; --firion-blue: #5357fb; --grass-green: #3a6b1a; --light-rock: #ce9141; --dark-rock: #784517; --river-blue: #69a3dc; --light-river-blue: #b1c7dd; --menu-blue: #1c1f82; --black: #000; --white: #dedede; --true-black: #000; --grey: #959595; }
} /* Kicks in if the user's site-level setting is light mode */
html.light { --pink: #fd7ed0; --firion-red: #bb4135; --firion-blue: #5357fb; --grass-green: #58ab1d; --dark-rock: #a15d14; --light-rock: #eba640; --river-blue: #75b9fd; --light-river-blue: #c8e3fe; --menu-blue: #252aad; --black: #0d1b2a; --white: #fff; --true-black: #000; --grey: #959595;
} /* Kicks in if the user's system setting is light mode */
@media screen and (prefers-color-scheme: light) { html { --pink: #fd7ed0; --firion-red: #bb4135; --firion-blue: #5357fb; --grass-green: #58ab1d; --dark-rock: #a15d14; --light-rock: #eba640; --river-blue: #75b9fd; --light-river-blue: #c8e3fe; --menu-blue: #252aad; --black: #0d1b2a; --white: #fff; --true-black: #000; --grey: #959595; }
}

It’s repetitive—and I’m sure someone out there knows a better way—but it gets the job done. Regardless of the user’s explicit site-level preference, or their underlying system preference, the page ends up being reliably rendered in the appropriate color scheme.

Setting custom properties on the paint worklet

If the Paint API is supported, a tiny inline script in the document <head> applies a paint-api class to the <html> element.

/* The main content backdrop rendered at a max-width of 64rem. We don't want to waste CPU time if users can't see the background behind the content area, so we only allow it to render when the screen is 64rem (1024px) or wider. */
@media screen and (min-width: 64rem) { .paint-api .backdrop { background-image: paint(overworld); position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1; /* These oh-so-well-chosen property names refer to the theme-driven CSS variables that vary according to the user's preferred color scheme! */ --overworld-grass-green-color: var(--grass-green); --overworld-dark-rock-color: var(--dark-rock); --overworld-light-rock-color: var(--light-rock); --overworld-river-blue-color: var(--river-blue); --overworld-light-river-blue-color: var(--light-river-blue); }
}

There’s some weirdness here for sure. For some reason, that may or may not be the case later on—but is at least the case as I write this—you can’t render a paint worklet’s output directly on the <body> element.

Plus, because some pages can be quite tall, I don’t want the entire page’s background to be filled with randomly generated (and thus potentially expensive) artwork. To get around this, I render the paint worklet in an element that uses fixed positioning that follows the user as they scroll down, and occupies the entire viewport.

All quirks aside, the magic here is that the custom properties for the paint worklet are based on the user’s system—or site-level—color scheme preference because the CSS variables align with that preference. In the case of the overworld paint worklet, that means I can adjust its output to align with the user’s preferred color scheme!

Not bad! But this isn’t even that inventive of a way to control how paint worklets render. If I wanted, I could add some extra details that would only appear in a specific color scheme, or do other things to radically change the rendering or add little easter eggs. While I learned a lot this year, I think this intersection of APIs was one of my favorites.