- Published on
The Ultimate Guide to Creating a Dark/Light Mode Toggle with CSS Variables & JavaScript
- Authors
- Name
- Md Nasim Sheikh
- @nasimStg
'The Ultimate Guide to Creating a Dark/Light Mode Toggle with CSS Variables & JavaScript'
Learn how to build a robust, maintainable, and user-friendly dark/light mode toggle for your website using the power of CSS Custom Properties (Variables) and a sprinkle of JavaScript.
Table of Contents
- 'The Ultimate Guide to Creating a Dark/Light Mode Toggle with CSS Variables & JavaScript'
- A Tale of Two Themes: Why Dark Mode Matters
- Section 1: The Foundation - Why CSS Variables are a Game-Changer for Theming
- The Syntax
- Section 2: Building Our Color Palette (Light Mode First)
- The HTML (index.html)
- The CSS (style.css)
- Section 3: Defining the Dark Theme with a Data Attribute
- Section 4: Adding the JavaScript Magic for Toggling
- Section 5: Best Practice - Respecting User OS Preferences
- Section 6: Making it Persistent with localStorage
- The Ultimate Fix: The <head> Script
- Section 7: Polishing the Experience - Smooth Transitions
- Conclusion: You've Mastered the Toggle
A Tale of Two Themes: Why Dark Mode Matters
It's hard to browse the web these days without encountering it: the sleek, eye-friendly allure of dark mode. Once a niche feature for developers and power users, it has exploded into a mainstream expectation. From operating systems like macOS and Windows to major apps like Twitter, Slack, and YouTube, users love having the choice.
But why all the fuss?
- Reduced Eye Strain: In low-light environments, a dark background with light text can be significantly more comfortable to read.
- Improved Battery Life: On devices with OLED or AMOLED screens, black pixels are essentially turned off, which can lead to noticeable power savings.
- User Preference & Accessibility: Simply put, some people just prefer it. Giving users control over their experience is a cornerstone of good design and accessibility.
As a web developer, implementing a theme toggle is no longer a luxury; it's a feature that demonstrates care and attention to detail. In this comprehensive guide, we'll walk through exactly how to build a robust, maintainable, and polished dark/light mode toggle from scratch. We'll harness the modern power of CSS Custom Properties (Variables) and enhance it with JavaScript for interactivity and persistence.
By the end, you'll have a system that not only works flawlessly but is also incredibly easy to scale and manage. Let's get started!
Section 1: The Foundation - Why CSS Variables are a Game-Changer for Theming
Before we write a single line of theme-specific code, we need to understand our most important tool: CSS Variables, officially known as Custom Properties for Cascading Variables.
If you've ever used a CSS preprocessor like Sass or Less, you're familiar with variables like $primary-color
. CSS Variables are similar, but with one superpower: they are live and exist in the browser.
Sass variables are compiled away. When you run your build process, $primary-color
is replaced with #3498db
everywhere it's used. You can't change it in the browser without recompiling.
CSS Variables, however, remain in the CSSOM (CSS Object Model). This means we can update them with JavaScript in real-time, and the browser will instantly repaint the page with the new values. This is the magic that makes dynamic theming not just possible, but elegant.
The Syntax
The syntax is straightforward.
1. Defining a variable: You define a variable using a double-hyphen prefix (--
) and assign it a value. It's best practice to define global variables within the :root
pseudo-class, which corresponds to the <html>
element.
:root {
--primary-color: #3498db;
--font-size-base: 16px;
}
2. Using a variable: You use the variable with the var()
function.
body {
font-size: var(--font-size-base);
}
a {
color: var(--primary-color);
}
This separation of value definition from property usage is the key. We can change --primary-color
once in the :root
, and every element using it will update automatically. This is the architectural foundation of our theme switcher.
Section 2: Building Our Color Palette (Light Mode First)
Every good design starts with a solid color palette. We'll begin by defining our default theme, which will be light mode. The trick here is to use semantic variable names. Instead of naming variables after the color itself (e.g., --light-grey
, --almost-black
), we'll name them based on their function.
This makes your theme logic much easier to understand and maintain.
Let's set up a basic HTML structure and our CSS with the light theme variables.
index.html
)
The HTML (Here's a simple page layout we can use to see our theme in action.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dark/Light Mode Toggle</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>Themed Website</h1>
<button id="theme-toggle">Toggle Theme</button>
</header>
<main>
<h2>Welcome to the Guide</h2>
<p>This is a paragraph demonstrating the text color and background. We'll use CSS variables to make this magic happen.</p>
<div class="card">
<h3>A Themed Card</h3>
<p>This card has its own background color, which will also change with the theme.</p>
<a href="#">A Link Inside</a>
</div>
<button class="primary-button">Primary Action</button>
</main>
<script src="app.js"></script>
</body>
</html>
style.css
)
The CSS (Now, let's define our light theme variables in :root
and apply them to our elements.
/* 1. Define Light Theme Variables in :root */
:root {
--color-text: #1a1a1a;
--color-background: #f5f5f5;
--color-primary: #007bff;
--color-primary-hover: #0056b3;
--color-card-background: #ffffff;
--color-card-shadow: rgba(0, 0, 0, 0.1);
--border-radius: 8px;
}
/* 2. Basic Styles & Applying Variables */
body {
background-color: var(--color-background);
color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 2rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
h1, h2, h3 {
color: var(--color-text);
}
a {
color: var(--color-primary);
}
.card {
background-color: var(--color-card-background);
border-radius: var(--border-radius);
padding: 1.5rem;
box-shadow: 0 4px 8px var(--color-card-shadow);
margin-bottom: 1.5rem;
}
.primary-button {
background-color: var(--color-primary);
color: #ffffff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 1rem;
}
.primary-button:hover {
background-color: var(--color-primary-hover);
}
#theme-toggle {
/* We'll style this more later */
padding: 0.5rem 1rem;
cursor: pointer;
}
At this point, you have a fully styled, albeit static, light-themed page. All the colors and key styles are powered by our variables.
Section 3: Defining the Dark Theme with a Data Attribute
Here comes the elegant part. How do we define our dark theme? We don't need a separate stylesheet. We don't need to override every single rule. We just need to redefine our variables.
We'll use a data attribute on the <html>
element, like data-theme="dark"
, to act as our switch. Then, in our CSS, we can create a selector html[data-theme='dark']
to house the new variable values.
Add this to the bottom of your style.css
file:
/* 3. Define Dark Theme Variables */
html[data-theme='dark'] {
--color-text: #f5f5f5;
--color-background: #1a1a1a;
--color-primary: #3391ff;
--color-primary-hover: #5ca9ff;
--color-card-background: #2c2c2c;
--color-card-shadow: rgba(255, 255, 255, 0.1);
}
That's it. That's the entire dark theme definition.
Notice we didn't have to redefine --border-radius
because it doesn't change between themes. The beauty of this approach is its scalability. When you add a new component, you style it using the existing semantic variables. It will automatically be theme-aware without you needing to add any extra CSS to the html[data-theme='dark']
block, unless that new component introduces entirely new colors.
To test this, go into your index.html
file and manually add the attribute: <html lang="en" data-theme="dark">
. Refresh your page, and you'll see the dark theme instantly applied!
Section 4: Adding the JavaScript Magic for Toggling
Manually changing the HTML is great for testing, but we need a button to do it for us. This is where JavaScript comes in to toggle the data-theme
attribute.
Create a new file, app.js
.
// app.js
document.addEventListener('DOMContentLoaded', () => {
const themeToggle = document.getElementById('theme-toggle');
const htmlElement = document.documentElement; // Gets the <html> element
themeToggle.addEventListener('click', () => {
// Check the current theme
const currentTheme = htmlElement.getAttribute('data-theme');
if (currentTheme === 'dark') {
// Switch to light mode
htmlElement.setAttribute('data-theme', 'light');
} else {
// Switch to dark mode
htmlElement.setAttribute('data-theme', 'dark');
}
});
});
Let's break this down:
- We wait for the DOM to be fully loaded to ensure all elements are available.
- We grab our toggle button and the
<html>
element (usingdocument.documentElement
). - We add a
click
event listener to the button. - Inside the listener, we get the current value of the
data-theme
attribute. - If it's
'dark'
, we change it to'light'
. Otherwise, we change it to'dark'
. This simple conditional logic handles the toggling.
Now, refresh your page (after removing the manual data-theme
attribute from the HTML file) and click the button. Voilà! You have a working theme switcher.
Section 5: Best Practice - Respecting User OS Preferences
A great user experience often means meeting users where they are. Many users have already set a system-wide color preference in their OS (e.g., macOS Dark Mode). We can detect this preference using the prefers-color-scheme
CSS media query.
We can use this to set a sensible default theme on the user's very first visit, before they've ever clicked our toggle.
Let's update our CSS. We'll modify our dark theme rule.
/* CSS - style.css */
/* Set light theme as the default */
:root {
/* ... same light theme variables ... */
}
/* Apply dark theme if the user's OS is in dark mode,
AND the user hasn't manually chosen a theme with the toggle */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--color-text: #f5f5f5;
--color-background: #1a1a1a;
--color-primary: #3391ff;
--color-primary-hover: #5ca9ff;
--color-card-background: #2c2c2c;
--color-card-shadow: rgba(255, 255, 255, 0.1);
}
}
/* Also apply dark theme if the user has manually chosen it */
html[data-theme='dark'] {
--color-text: #f5f5f5;
--color-background: #1a1a1a;
--color-primary: #3391ff;
--color-primary-hover: #5ca9ff;
--color-card-background: #2c2c2c;
--color-card-shadow: rgba(255, 255, 255, 0.1);
}
This logic is a bit more complex, so let's break it down:
- The
@media (prefers-color-scheme: dark)
block applies its styles only if the user's OS is set to dark mode. - The
:root:not([data-theme='light'])
selector is clever. It says "apply these dark variables to the root element, unless it has an attributedata-theme='light'
." This means a user's explicit choice to be in light mode will override their OS dark mode preference. - The
html[data-theme='dark']
rule remains. This ensures that if a user with a light OS preference clicks our toggle, they can still switch to dark mode.
This creates a perfect hierarchy: User's explicit toggle choice > User's OS preference > Site default (light).
localStorage
Section 6: Making it Persistent with We have a problem. If you switch to dark mode and then refresh the page, it reverts to the default. The browser has no memory of your choice. We can easily fix this with localStorage
, a simple key-value storage system in the browser.
We need to update our JavaScript to do two things:
- On toggle: Save the chosen theme to
localStorage
. - On page load: Check
localStorage
for a saved theme and apply it immediately.
Here is the final, robust app.js
.
// app.js
const themeToggle = document.getElementById('theme-toggle');
const htmlElement = document.documentElement;
// 1. Function to apply the saved theme on page load
const applyTheme = () => {
const savedTheme = localStorage.getItem('theme') || 'light'; // Default to light
htmlElement.setAttribute('data-theme', savedTheme);
// Optional: Update toggle button text/state
themeToggle.textContent = savedTheme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode';
};
// 2. Function to toggle the theme and save the preference
const toggleTheme = () => {
const currentTheme = htmlElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
htmlElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
// Optional: Update toggle button text/state
themeToggle.textContent = newTheme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode';
};
// 3. Add event listener to the toggle button
themeToggle.addEventListener('click', toggleTheme);
// 4. Apply the theme when the DOM is loaded
document.addEventListener('DOMContentLoaded', applyTheme);
Wait! There's a catch. If you put this script at the end of your <body>
, you might see a brief flash of the default theme before the JavaScript runs and applies the saved dark theme. This is known as a Flash of Unstyled Content (FOUC), or in our case, a Flash of Incorrect Theme (FOIT).
<head>
Script
The Ultimate Fix: The To prevent the flash, the theme-applying logic must run before the browser starts rendering the page body. The best way to do this is to place a small, blocking script in the <head>
of your HTML.
Let's refine our approach.
Updated index.html
:
<!DOCTYPE html>
<html lang="en"> <!-- No data-theme here initially -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dark/Light Mode Toggle</title>
<script>
// This script runs before the page is rendered
(() => {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
} else {
// If no theme is saved, check the OS preference
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
document.documentElement.setAttribute('data-theme', 'dark');
}
// No need for an 'else' block, the default is light
}
})();
</script>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- ... rest of your body ... -->
<script src="app.js"></script> <!-- Keep this for the interactive part -->
</body>
</html>
Updated app.js
(now simplified):
Our app.js
file, which still loads at the end of the <body>
, is now only responsible for the click event.
// app.js (simplified)
const themeToggle = document.getElementById('theme-toggle');
const htmlElement = document.documentElement;
themeToggle.addEventListener('click', () => {
const currentTheme = htmlElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
htmlElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
});
This is the most professional and robust setup. The head script eliminates the flash, and the body script handles user interaction. It gracefully falls back from localStorage
to OS preference to the CSS default.
Section 7: Polishing the Experience - Smooth Transitions
Our toggle works perfectly, but the change is instantaneous and a bit jarring. A simple CSS transition can make the switch feel smooth and professional.
In your style.css
, add a transition
to the elements whose colors are changing. A good place to start is the body
and our .card
.
/* style.css */
body {
/* ... other body styles ... */
transition: background-color 0.3s ease, color 0.3s ease;
}
.card {
/* ... other card styles ... */
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
.primary-button {
/* ... other button styles ... */
transition: background-color 0.3s ease;
}
/* It's good practice to add transitions to any element
whose color properties are defined by your theme variables */
Now, when you click the toggle, the background colors and text colors will gracefully fade from one theme to the other over 0.3 seconds. It's a small touch that makes a huge difference in perceived quality.
Conclusion: You've Mastered the Toggle
Congratulations! You've gone from zero to a fully-featured, professional-grade dark/light mode toggle. Let's recap what makes this approach so powerful:
- CSS Variables: We used them to create a centralized, easily updatable theme definition.
- Semantic Naming: Our variables (
--color-background
, not--white
) make the theme logic clear and scalable. - Data Attributes: A simple
data-theme
attribute on the<html>
element acts as our primary switch, controlled by JavaScript. - User Preference First: We use
prefers-color-scheme
to respect the user's OS-level settings for a better initial experience. - Persistence with
localStorage
: We remember the user's explicit choice across page loads. - No Flashing: By placing a tiny script in the
<head>
, we eliminate the flash of an incorrect theme, ensuring a seamless experience. - Smooth Transitions: A touch of CSS
transition
adds the final layer of polish.
This pattern is robust, modern, and highly performant. You can now take this foundation and expand upon it with more complex components, additional themes (like a high-contrast mode), or more intricate toggle switch designs. You have the control, and now, so do your users.