We recently added a new dark mode to our website.
Dark modes have become a very popular feature for apps and operating systems in recent years, and many users prefer the inverted colors because they're easier on the eyes. Our new dark UI looks gorgeous and has a few extra tricks up its sleeve too - here's how we made it.
Setting the Mood
First things first: the basis of most dark modes on the web these days are CSS custom properties - they let us define all our colors as variables we can change at runtime. There are many good tutorials on how to use them already, so we'll skip over that. For an existing site, the initial switch to custom properties can be a bit cumbersome, but find & replace is very helpful 😉.
We are working with Sass here, so it's a good idea to define the color schemes as mixins, so they're easier to reuse in different selectors. When it comes to naming the properties, try to stay away from visual names like "light" or "gray" - the other color scheme might invert that or use different hues, and things can get confusing.
In our case, we went with a "color scale" from background to foreground, and three highlight colors for our brand.
// define the color vars as a mixin so we can reuse it
@mixin color-scheme-dark {
--color-scale-0: #001e2f;
--color-scale-25: #143044;
--color-scale-50: #576975;
--color-scale-75: #e6f1f8;
--color-scale-100: #ffffff;
--color-brand-primary: #7a27ff;
--color-brand-secondary: #26ffae;
--color-brand-tertiary: #ed667b;
}
// default to light colors...
:root {
@include color-scheme-light;
}
// ...except if no theme is set and the system preference is dark
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
@include color-scheme-dark;
}
}
// override defaults through the data-theme attribute.
:root[data-theme='dark'] {
@include color-scheme-dark;
}
We apply the light color scheme per default, and then override that based on other conditions. It's a good idea to check for the system preference in the prefers-color-scheme
media query, but still give users the choice to toggle modes themselves, to adjust for whatever situation they're currently in.
To do that, we need to expose the dark mode variable through our website's UI.
Build the Light Switch
The light switch to toggle modes is just a simple button. We can define it as a thing with an on/off state by using role="switch"
and the aria-checked
attribute.
We put an SVG icon in there that has an accessible title and two paths for the "on" and "off" icons respectively. We show/hide these in CSS based on the documents data-theme
attribute.
<!-- hook into js-darkmode-toggle via Javascript later -->
<button class="lightswitch js-darkmode-toggle" role="switch" aria-checked="false">
<svg class="lightswitch__icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<title>toggle dark mode</title>
<!-- include two different icon paths, then display the one matching the current state in CSS -->
<path class="lightswitch__icon__on" d="M9 20...1.512z"/>
<path class="lightswitch__icon__off" d="M9 20...6h-2z"/>
</svg>
</button>
Wire up the Power
The switch looks good, but still needs some Javascript to actually do something. The main task here is to check which mode of the UI the user is currently seeing, and then to toggle the data-theme
attribute to trigger the changes in CSS. We use a cookie to save the current theme setting, so we can persist the choice when somebody navigates through multiple pages, or even closes their browser and comes back later.
If no cookie is set, we fall back to the system default again by checking the prefers-color-scheme
media query.
class DarkMode {
constructor() {
// define some properties
this.isActive = false;
this.cookieTimeoutDays = 30;
this.cookieName = "darkMode";
this.toggleBtn = document.querySelectorAll(".js-darkmode-toggle");
this.init();
}
init() {
// get initial setting, add event listeners
this.isActive = this.getPreference();
this.toggleBtn.addEventListener("click", this.toggle);
}
getPreference() {
// read if the user has selected a theme before,
// otherwise fall back to media query for system defaults.
const cookie = Cookies.get(this.cookieName);
const systemPreference = window.matchMedia("(prefers-color-scheme: dark)")
.matches;
switch (cookie) {
case "true":
return true;
case "false":
return false;
default:
return systemPreference;
}
}
toggle() {
// switch between light and dark
this.isActive = !this.isActive;
// set the data attribute to trigger CSS changes
document.documentElement.setAttribute(
"data-theme",
this.isActive ? "dark" : "light"
);
// update the cookie to save the user preference
Cookies.set(this.cookieName, String(this.isActive), {
expires: this.cookieTimeoutDays,
});
// toggle the button state for screen readers
this.toggleBtn.setAttribute("aria-checked", String(this.isActive));
}
}
// this feature only makes sense if CSS custom properties are supported,
// so check for that first before we kick things off
if (window.CSS && CSS.supports("color", "var(--fake-var)")) {
new DarkMode();
}
The result looks something like this - Go ahead and try it by clicking the button here (or the one in our site's header)
Make it Click
A nice little detail here is the small "clicking" sound the toggle makes. It makes it feel a lot more natural, like a real switch we can flick on or off. A good resource for audio clips like this is freesound.org - it only requires a free account and has tons of material.
We can add the sounds as audio
elements to the DOM and preload
them so they can play instantly, which is crucial for the effect to work.
<audio src="click1.mp3" id="js-sound-off" preload="auto" hidden></audio>
<audio src="click2.mp3" id="js-sound-on" preload="auto" hidden></audio>
Hooking an extra method into the toggle()
function, we can then select the appropriate sound and play it every time the button is pressed.
playSound() {
const sound = this.isActive ? this.soundOn : this.soundOff;
// always start the sound from beginning, in case it's
// still playing from a previous action
sound.currentTime = 0;
sound.play();
}
Glow up
So far so good, but our darkmode could still use a little more spark. A nice thing about dark backgrounds is that it opens up more possibilities to play with light in a design. It's not just about inverting the colors - we can give certain elements special treatment to make them stand out even more in the dark.
In our case, the Codista brand design has lots of bright, synthetic colors that are perfect for a "neon" effect:
Achieving that look is quite easy: we can layer a few different text-shadows
on top of large type, like headlines. The effect works by setting the text color to white, fuzzing the edges a bit with some white text-shadow, then adding the neon color on top with increasing amounts of blur. It's a good idea to define the blur size in relative em
units here, so the glow will scale with the font-size.
We can set up a Sass mixin again to call this with different color variables later, i.e. to switch colors between primary and secondary headlines.
@mixin neon-glow($color: currentColor) {
color: #fff;
text-shadow:
0 0 0.033em #fff,
0 0 0.08em #fff,
0 0 0.1em $color,
0 0 0.2em $color,
0 0 0.3em $color,
0 0 1em $color,
0 0 1.5em $color;
}
[data-theme='dark'] {
h1 {
@include neon-glow(var(--color-brand-primary));
}
h2 {
@include neon-glow(var(--color-brand-secondary));
}
}
The glow effect also works with other UI elements, like cards or call-to-action links. The exact implementation depends on the design - for the Codista website, we leveraged border
and box-shadow
properties to create neon edges and glows. It's best not to overdo the effect though - we use it for interactions like hover or focus.
Flicker Animation
For the final touch, we gave our headlines the typical neon sign look where one of the letters is short-circuited and flickers. It looks like this:
To make that work, we first select some elements to apply the animation to. It works best with large type and should be used sparingly, so we went with the page's <h1>
only.
Inside the DarkMode.js
class, we can now run a function to wrap some of the letters of the h1 in a span
, which we can then target for animation in CSS later.
function setFlickerAnimation() {
// get all elements that should be animated
const animatedElements = Array.from(
document.querySelectorAll(".js-darkmode-flicker")
);
if (!animatedElements.length) {
return false;
}
// helper function to wrap random letters in <span>
const wrapRandomChars = (str, iterations = 1) => {
const chars = str.split("");
const excludedChars = [" ", "-", ",", ";", ":", "(", ")"];
const excludedIndexes = [];
let i = 0;
// run for the number of letters we want to wrap
while (i < iterations) {
const randIndex = Math.floor(Math.random() * chars.length);
const c = chars[randIndex];
// make sure we don't wrap a space or punctuation char
// or hit the same letter twice
if (!excludedIndexes.includes(randIndex) && !excludedChars.includes(c)) {
chars[randIndex] = `<span class="flicker">${c}</span>`;
excludedIndexes.push(randIndex);
i++;
}
}
return chars.join("");
};
// replace the plain text content in each element
animatedElements.forEach((el) => {
const text = el.textContent.trim();
const count = el.dataset.flickerChars ? parseInt(el.dataset.flickerChars) : undefined
el.innerHTML = wrapRandomChars(text, count);
});
}
The letters are chosen at random, so the effect is a bit different every time!
We can now select the .flicker
class in CSS and apply a keyframe animation that toggles the element's opacity. We're going for a hard on/off flickering here rather than a slower transition, so the increments between the keyframe steps are very short.
⚠️ Attention: people with epilepsy or photosensitivity can have issues with blinking/flickering text. So it's very important to listen for signals like the prefers-reduced-motion
media query to disable the effect. We can do that by setting the animation-duration
property to zero.
// keyframe animation to abruptly toggle
// the letter's opacity value.
@keyframes flicker {
0%,
19.999%,
22%,
62.999%,
64%,
64.999%,
72%,
100% {
opacity: 1;
}
20%,
21.999%,
63%,
63.999%,
65%,
71.999% {
opacity: 0.33;
}
}
// only run in dark mode. for every other letter, offset the animation
// and reverse its direction, so the flickering appears more random.
[data-theme='dark'] .flicker {
animation: flicker 3s linear forwards alternate infinite;
&:nth-child(even) {
animation-delay: 0.3s;
animation-direction: alternate-reverse;
}
}
// if the user prefers reduced motion,
// disable the animation entirely.
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
}
Well, that's all folks! We love our new buzzing neon mode, and we hope you do too.