How to add Dark Mode using CSS variables and Stimulus, plus a color palette picker
Click the buttons below for a live demo of toggling dark mode and changing the primary color of this site.
By using CSS variables we can write CSS/SCSS that references variables instead of hardcoded values.
For example, instead of specifying a value for every color, background-color, border-color, etc you can simply replace the values with var(--<VARIABLE_NAME>)
syntax.
Before using hardcoded values:
.color-primary {
color: #17bf63;
}
.text-bright {
color: #1a202c;
}
.text-color {
color: #4a5568;
}
.text-muted {
color: #718096;
}
.text-faint {
color: #a0aec0;
}
.bg-bright {
background-color: #fff;
}
.bg-gray {
background-color: #f7fafc;
}
.border-gray {
border-color: #edf2f7;
}
After using CSS variables:
.color-primary {
color: var(--color-primary);
}
.text-bright {
color: var(--text-bright);
}
.text-color {
color: var(--text-color);
}
.text-muted {
color: var(--text-muted);
}
.bg-bright {
background-color: var(--bg-bright);
}
.bg-gray {
background-color: var(--bg-gray);
}
.border-gray {
border-color: var(--border-gray);
}
Then simply add CSS for :root { ... }
as a base for color and dark mode. The below code sets the default dark mode to “light” and then only applies “dark” if the viewer’s browser/OS “prefers-color-scheme” is “dark”.
The body
tags allow using JS to add a user to set their preference to override the defaults.
// Default colors
:root {
--color-primary: #17bf63; // rgb(23, 191, 99, 1)
// Light is default
--bg-bright: #fff; // bg-white
--bg-gray: #f7fafc; // gray-100
--border-gray: #edf2f7; // gray-200
--text-bright: #1a202c; // gray-900
--text-color: #4a5568; // gray-700
--text-muted: #718096; // gray-600 -- Same for light/dark
--text-faint: #a0aec0; // gray-500
// Dark if preferred at OS level by user
@media (prefers-color-scheme: dark) {
--bg-bright: #1a202c; // gray-900
--bg-gray: #131720; // bg-gray-930
--border-gray: #202837; // gray-870
--text-bright: #edf2f7; // gray-200
--text-color: #a0aec0; // gray-500
// --text-muted: #718096; // gray-600
--text-faint: #4a5568; // gray-700
}
}
// If user toggles to "light" mode
body[data-color-scheme='light'] {
--bg-bright: #fff; // bg-white
--bg-gray: #f7fafc; // gray-100
--border-gray: #edf2f7; // gray-200
--text-bright: #1a202c; // gray-900
--text-color: #4a5568; // gray-700
// --text-muted: #718096; // gray-600
--text-faint: #a0aec0; // gray-500
}
// If user toggles to "dark" mode
body[data-color-scheme='dark'] {
--bg-bright: #1a202c; // gray-900
--bg-gray: #131720; // bg-gray-930
--border-gray: #202837; // gray-870
--text-bright: #edf2f7; // gray-200
--text-color: #a0aec0; // gray-500
// --text-muted: #718096; // gray-600
--text-faint: #4a5568; // gray-700
}
// If user toggles the color to ...
body[data-color-primary='teal'] {
--color-primary: #00bcd4; // rgb(0, 187, 211)
}
body[data-color-primary='pink'] {
--color-primary: #ec407a; // rgb(236, 64, 122)
}
To allow the user to pick dark mode or choose a color, we’ll add Stimulus.
To set up Stimulus you can follow their docs, or see the code powering this blog.
First, add Stimulus @hotwired/stimulus
to your package.json and create an entrypoint / main JS file similar to this:
import { Application } from '@hotwired/stimulus'
import ColorSchemeController from './controllers/color_scheme_controller'
window.Stimulus = Application.start()
Stimulus.register('color-scheme', ColorSchemeController)
Then add to the HTML body
tag in your app some code to tell Stimulus to interact with this color-scheme
controller:
<body data-controller="color-scheme" class="..."></body>
Then add a Stimulus controller similar to this one:
// ./controllers/color_scheme_controller.js
import { Controller } from '@hotwired/stimulus'
import DevLog from './shared/DevLog'
const COLORS = ['green', 'teal', 'pink']
const DARK_SCHEME = 'dark'
const LIGHT_SCHEME = 'light'
const SCHEME_KEY = 'appScheme'
const COLOR_KEY = 'appColor'
export default class extends Controller {
// Read from the getter and write that value to the setter.
initialize() {
this.appScheme = this.currentScheme
this.appColor = this.currentColor
}
// Unlike initialize, calling toggle persists the change in localStorage
toggleScheme(e) {
e.preventDefault()
let scheme = this.currentScheme === DARK_SCHEME ? LIGHT_SCHEME : DARK_SCHEME
this.appScheme = scheme
this.storeScheme = scheme
}
toggleColor(e) {
e.preventDefault()
let colorIndex = COLORS.findIndex((k) => k === this.currentColor)
let color = COLORS[colorIndex + 1] || COLORS[0]
this.appColor = color
this.storeColor = color
}
// Private
/* eslint-disable class-methods-use-this */
set appScheme(val) {
document.body.dataset.colorScheme = val
}
set appColor(val) {
document.body.dataset.colorPrimary = val
}
set storeScheme(val) {
localStorage.setItem(SCHEME_KEY, val)
}
set storeColor(val) {
localStorage.setItem(COLOR_KEY, val)
}
// Check localStorage first for preference, then check OS.
get currentScheme() {
const fromLocal = localStorage.getItem(SCHEME_KEY)
if (fromLocal) {
DevLog(['Color scheme found in localStorage', fromLocal])
return fromLocal
}
const darkFromOS = window.matchMedia('(prefers-color-scheme: dark)').matches
if (darkFromOS) {
DevLog(['Color scheme found at OS level as dark'])
return DARK_SCHEME
}
// Default
return LIGHT_SCHEME
}
// Check localStorage first for preference.
get currentColor() {
const fromLocal = localStorage.getItem(COLOR_KEY)
if (fromLocal) {
DevLog(['Color primary found in localStorage', fromLocal])
return fromLocal
}
return COLORS[0]
}
/* eslint-enable class-methods-use-this */
}
Finally add some HTML buttons to allow toggling:
<button type="button" data-action="color-scheme#toggleScheme">
Toggle dark mode
</button>
<button type="button" data-action="color-scheme#toggleColor">
Change primary color
</button>
</div>
That’s it! Try it out here: Live Demo