With the upcoming launch of the redesigned Safari themes are all the rage. It's not a new feature. Chrome on Android had it for years, Vivaldi brought it to Desktop, Apple brings new attention to it.
Theming can be much more than just providing a meta tag, though. Let's take a close look at how users can customize a website to their own preferences and how to implement them in a clean, fast and modern way and wrap everything up in a small, clean boilerplate template.
A good technique
Implementing themes can be a challenging technical task. We want the theme to be available as fast as possible, preventing any flashes of wrong styles. We also want to consider all the hints the user gives us to select the most fitting theme. We want our users to be in control and able to select a theme for themselves. That's a lot of variables to consider. Maybe it's time to stop thinking about themes as monolithic stylesheets and explore some more fluid choices. Think of it the same way Responsive Webdesign provided a more fluid solution to strictly separated solutions for large and small screens.
Automatic dark mode
The instruction color-scheme
comes as both a media query and a meta tag. It tells the browser which color schemes are supported, which is preferred, and which is enforced and the browser reacts by applying sensible defaults.
This promises to be a great way to create minimal dark themes or provide a boilerplate for more intricate designs since we don't have to write our own dark styles for general UI elements like buttons and input fields anymore. It's part of the color scheme spec, which for now accepts only dark
and light
and no custom color themes. But that doesn't mean there won't be more options in the future. Both the intention to expand this query and the fact that its values have meaningful names should prompt us to use this media query as a list of options rather than an on/off switch for dark mode.
It's currently supported only by Chrome and Safari, while only Chrome sets usable defaults across all elements. Safari won't change the style of input elements, nor the background color, which can lead to white-on-white text.
In any case, some more styling with more resilient methods is required.
Media Queries
Just as Media Queries were the secret sauce behind Responsive Design, they take the same role here. That means, we can let our website react to individual properties that are exposed to it by the browser.
User Preferences
The prefers-color-scheme
media query determines what theme the user's device is set to. Like the color-scheme
instruction, it only accepts dark
and light
as options. While color-scheme
sets a certain scheme on the document or a selector, prefers-color-scheme
reacts to a scheme.
body {
background-color: white;
color: black;
}
@media (prefers-color-scheme: dark) {
body {
background-color: black;
color: white;
}
}
High contrast mode
The media query to react to High Contrast Mode is sadly split up between Apple and the rest.
When a user with enabled high contrast mode visits our website from a Windows or Android system, the browser simply disables all our color choices and forces its own, ensuring accessible colors at all costs. We can only react to that by querying forced-colors
. Since all color instructions are void at this point, the motivation behind this media query is layout. MDN lists a good example that changes a dashed to a solid line.
Apple does no such drastic methods. Safari can only react to prefers-contrast
by matching it against more
or less
. At this point, we have to implement a high contrast theme ourselves and do the right thing by maxing out contrasts manually. I'd even go as far and say that this is one of the rare chances to use !important
liberally.
@media (prefers-contrast: more) {
* {
background: black !important;
color: white !important;
}
*::selection {
background: cyan !important;
color: black !important;
}
a,
a *,
a:hover,
a:hover *,
a:active,
a:active * {
color: yellow !important;
border-color: yellow !important;
}
a:visited,
a:visited * {
color: greenyellow;
border-color: yellow !important;
}
button {
background: black !important;
color: white !important;
border: 0.2ch solid cyan !important;
}
}
Custom Properties
Combining Media Queries with Custom Properties enables us to write truly adaptive styles. We could overwrite default styles with media queries just like classic responsive Layouts do, but that would already be overkill. We end up writing complex media queries and potentially introduce side effects with the layout when we only want to change some colors.
Custom Properties are the perfect tool for that because they can change depending on their context. That way we can use the cascade to apply the colors we want in each condition and inherit them to all further selectors.
:root {
--background-color: white;
--font-color: black;
}
@media (prefers-color-scheme: dark) {
:root {
--background-color: black;
--font-color: white;
}
}
body {
background: var(--background-color);
color: var(--font-color);
}
But we can do even more. Just like CSS layouting shifts from hardcoded elements to programmatic layout systems with tools like flex
, grid
, and Container Queries, we can create color systems with the help of calc
and hsl
.
The classic color formats hex and RGB calculate colors by mixing the three base colors red, blue, and green. While this gives us control over exact color values, controlling certain aspects of color is quite hard. The HSL format addresses colors by their hue, saturation, and lightness properties. Lightness comes in very handy for this matter. It allows us to programmatically different versions of a color archetype, like dark and light versions, contrasted and even graded ones, while still being on a consistent tone. If you used a CSS preprocessor like Sass or Less, you might be familiar with their color functions. We'll do just that in plain CSS now.
:root {
--theme-hue: 220deg;
--theme-sat: 10%;
--theme-lit: 100%;
}
@media (prefers-color-scheme: dark) {
root {
--theme-lit: 20%;
}
}
body {
--background-color: hsl(
var(--theme-hue),
var(--theme-sat),
var(--theme-lit)
);
--font-color: hsl(
var(--theme-hue),
var(--theme-sat),
clamp(0%, calc(100% - (var(--theme-lit) - 47%) * 1000), 100%)
);
/* 47% seems a good threshold for the blue tone at 220deg */
background: var(--background-color);
color: var(--font-color);
}
Now we can control the entire theme from a few variables and tweak all our theme colors with global hue, saturation, and lightness values. Better yet, we can introduce automatic switches. --font-color
sets itself to a dark or light shade of the theme color based on the global lightness value. We'll have automatic font contrast now. Those kinds of switches can come in handy for all sorts of things.
We need to keep in mind that our color switch needs to be adjusted for each hue and saturation value to keep the contrast to the background color accessible. HSL's visual lightness is not consistent as we change the other values. A yellow hue will always be brighter than a blue one, even at the same lightness values.
The tool to fix that would be the LCH (Lightness Chroma Hue) color format. It keeps the visual brightness consistent. We'll see that when changing the hue at a constant saturation and lightness, then converting the resulting color to grayscales. The HSL values vary a great deal more than the LCH ones, making it harder to determine accessible font contrasts.
:root {
--background-color: lch(
var(--theme-lit),
var(--theme-sat),
var(--theme-hue)
);
/* needs less adjustment */
--font-color: lch(
clamp(0%, calc(100% - (var(--theme-lit) - 47%) * 1000), 100%),
var(--theme-sat),
var(--theme-hue)
);
}
I opted to use HSL, because LCH is still in its experimental stage as of now and its browser support is nonexistent. Lea Verou has written a very insightful article on how it works.
User Preferences
Using only media queries, our design can adapt to the user's system. If their browser operates in dark mode, so will the website. But sometimes users want to use the other design nonetheless. An example: the OLED screen of a mobile phone consumes less energy when displaying darker colors. Activating dark mode system-wide is a sensible decision. However white text on a black background is harder to read - especially in sunlight, where mobile phones sometimes end up. The user would want to enable the light theme for this specific website. We need to give our users a choice. Ideally, we would expose all options:
Because we set the theme with a data attribute instead of relying only on media queries now, we can expand our list of themes now. I included a cherry red one because the cherry season is ending and it's gonna be a while until we have fresh ones.
So, this solution will set the theme to the desired option, but it will not persist if the user refreshes or navigates to the next page. Where there's persistent states, localstorage
is not far:
const switchTheme = (e) => {
const theme = e.target.value;
document.body.dataset.theme = theme;
localStorage.theme = theme;
};
The JavaScript for the switch itself can be deferred. Users most likely don't need to switch themes as soon as the browser paints the page. However, we do need the information on which theme the user has selected in order to prevent a Flash of Inaccurate Color Theme (or FART, as Chris Coyier likes to abbreviate).
I consider render-blocking JavaScript mostly evil because it can quickly delay the Largest Contentful Paint of a website. But if we're careful about what we do (as in don't use loops, querySelectors
, or other slow operations), we can sneak in a few functions before the render while keeping the impact negligible.
// inlined inside the document <head>, so it's render blocking
if (window.localStorage && localStorage.theme) {
document.body.dataset.theme = localStorage.theme;
}
Tada! We have an immediate, persistent, and lightweight theme switch.
More than just CSS
CSS takes the lion's share of providing a theme, but we can do more to provide a consistent UX.
Themed Images
Now we can provide a dark theme to go easy on the user's eyes in dim light conditions, but then blast them with full-screen graphics with a white background. That's not a nice UX. Luckily, image source sets can react to media queries just the like CSS does, so we can provide toned-down graphics for dark mode users.
<picture>
<source srcset="cat-keytar-night.jpg" media="(prefers-color-scheme:dark)" />
<img
srcset="cat-keytar-day.jpg"
alt="A cat wearing a 90's retro jacket with a keytar"
/>
</picture>
We only have access to media queries that way, so there's no way to force a theme by setting a data attribute.
Themed SVGs
SVGs can be integrated into themes even better than pixel-based images because they're inherently code and can be styled by CSS. Everything we built above for the page layout can be adapted for SVGs.
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<style>
circle {
fill: #f1f3f4;
stroke: #0e181b;
stroke-width: 2;
}
@media (prefers-color-scheme: dark) {
circle {
fill: #0e181b;
stroke: #f1f3f4;
}
}
</style>
<circle cx="50" cy="50" r="40" />
</svg>
Inline SVGs can also react to the CSS of the surrounding document, including its custom properties. That makes integrating SVGs into the page's design a breeze since all colors can now come from a single source of truth in your CSS.
Setting colors for your browser
To integrate our color themes even more with the browser, we can provide some additional instructions.
Painting the browser
<meta
name="theme-color"
media="(prefers-color-scheme: light)"
content="#f1f3f4"
/>
<meta
name="theme-color"
media="(prefers-color-scheme: dark)"
content="#0e181b"
/>
The theme-color
meta tag sets supported browsers style their own UI (most notably the head area with the URL bar) accordingly to our website. Not all browsers support that feature, but with Safari's redesign in the coming version I think that's going to change. Please note that this instruction does not take the color-scheme
meta tag into consideration when we try to force a certain scheme, but always reacts to the device's color scheme instead.
When the browser UI color changes, so does the background color for our favicon. Since the favicon doesn't accept media queries, our best choice is to use an SVG, as described above. Eric Bailey describes some best practices for favicons, including their theme-ability.
Painting UI Elements
accent-color
and ::selection
are useful to style certain UI elements. Radio Buttons, Input Fields, and Range Sliders usually get a very distinct style directly from the browser (looking at you, Safari). Editing them isn't strictly necessary for a dark theme, since the browser already sets sensible defaults for those colors, but they're nice additions to colored themes.
input[type="checkbox"] {
accent-color: var(--accent-color);
}
input::selection {
background-color: var(--accent-color);
color: var(--accent-contrast-color)
}
Please note that --accent-color
is still very new and lacks browser support. Also, Safari doesn't seem to have plans to implement it yet, while still providing quirky user agent styles that may stray far from our theme colors. It's still important to normalize them.
Wrapping up
I created a boilerplate that can be used to quickly create a themeable website. It's not meant to be a complete solution, but it's a good starting point for a custom theme. It covers the most important parts of a theme, like the color scheme, the favicon, and the UI elements. In addition to that, be sure to provide themed images and SVGs and watch color contrasts.
(Cover image: Kotagauni Srinivas, Unsplash, edited)
Top comments (0)