Build a Dark Mode Toggle with CSS Variables and JavaScript
What You’ll Build
Final Outcome Overview
When you finish the tutorial you will have a single, self‑contained web page that:
- Displays a clean light‑theme layout by default (or matches the user’s system preference).
- Offers a clearly labelled toggle button (e.g., a moon / sun icon) that lets the visitor switch between light and dark modes.
- Updates every colour on the page instantly by swapping the values of CSS custom properties (variables).
- Uses the
prefers-color-schememedia query so the page respects the operating‑system theme on first load. - Is accessible: the button has
aria‑pressedand an accessible name, focus styles are visible, and colour contrast meets WCAG AA standards. - Includes a smooth transition so the colour change feels natural.
Key Visual Elements
The finished page consists of the following sections:
- Header – contains the page title and the theme toggle button aligned to the right.
- Main content – a heading, a paragraph of placeholder text, and a simple call‑to‑action button.
- Footer – a small copyright line that also responds to the active theme.
All background, text, and accent colours are derived from a small set of CSS variables, for example:
:root {
--bg-color: #ffffff;
--text-color: #222222;
--accent-color: #0066ff;
}
[data-theme="dark"] {
--bg-color: #121212;
--text-color: #e0e0e0;
--accent-color: #bb86fc;
}
The toggle button flips the data-theme attribute on the <html> element between light and dark, causing the variables to resolve to the appropriate values.
What You’ll See in the Browser
Initially the page might look like this (light mode):
- White background, dark text, blue accent button.
- Toggle button shows a moon icon, indicating that clicking it will activate dark mode.
After clicking the toggle, the page instantly transforms:
- Background becomes near‑black, text turns light gray, accent button changes to a soft violet.
- Toggle button now shows a sun icon, signalling that clicking again will return to light mode.
Practice Exercise
Once you have the base implementation working, try the following mini‑challenge:
- Replace the default accent colour with a shade of teal that you like. Update the variable in both the light and dark blocks.
- Add a second CSS variable for a
--border-colorand apply it to a thin border around the main content container. Make sure the border is visible in both themes. - Test the page on a device that uses a dark system theme. Verify that the page loads in dark mode automatically, without any click.
When you’ve completed the steps, you should be able to describe how each variable change affected the visual output, reinforcing the concept that a single variable controls many parts of the UI.
Result Checklist
- HTML file includes a button with
role="switch"andaria-pressedstates. - CSS defines the colour palette with custom properties and a
[data-theme="dark"]override. - JavaScript listens for
clickon the button, toggles thedata-themeattribute, and updatesaria-pressed. - The page respects
prefers-color-schemeon first load. - All text meets WCAG AA contrast ratios in both themes.
Concept Overview
Why Use CSS Custom Properties for Theming?
CSS custom properties (variables) let you define a value once and reuse it throughout your stylesheet. Because they participate in the normal CSS cascade, you can override them in a single place – such as on the <html> element – and every component that references the variable automatically updates.
This makes dark‑mode theming simple:
- Define a light palette in the root (
:root) scope. - Define a dark palette in a class (e.g.,
.dark) that is also attached to:root. - Swap the class with JavaScript, and the browser recomputes the values for the whole page.
Using prefers-color-scheme for the Initial Theme
Modern browsers expose the user’s system‑wide theme preference through the prefers-color-scheme media query. By matching this query in CSS you can automatically apply the appropriate palette before any JavaScript runs:
<style>
:root {
/* Light theme defaults */
--bg-color: #ffffff;
--text-color: #111111;
--link-color: #0066cc;
}
/* Dark theme overrides – applied when the .dark class is present */
.dark {
--bg-color: #111111;
--text-color: #eeeeee;
--link-color: #66aaff;
}
/* Auto‑apply dark theme if the OS prefers it */
@media (prefers-color-scheme: dark) {
:root {
/* Mirror the .dark overrides without needing JS */
--bg-color: #111111;
--text-color: #eeeeee;
--link-color: #66aaff;
}
}
/* Use the variables throughout the page */
body {
background: var(--bg-color);
color: var(--text-color);
}
a { color: var(--link-color); }
</style>
When the page loads, the browser checks the media query and sets the variables accordingly. If the user later toggles the theme, JavaScript adds or removes the .dark class, which overrides the same variables.
JavaScript Toggle Logic
The toggle button merely toggles a class on the <html> element. A minimal script looks like this:
<script>
const toggleBtn = document.getElementById('theme-toggle');
const root = document.documentElement; // <html> element
// Load saved preference (if any)
const saved = localStorage.getItem('theme');
if (saved) {
root.classList.toggle('dark', saved === 'dark');
}
// Click handler
toggleBtn.addEventListener('click', () => {
const isDark = root.classList.toggle('dark');
// Persist the choice for future visits
localStorage.setItem('theme', isDark ? 'dark' : 'light');
// Update ARIA attribute for screen readers
toggleBtn.setAttribute('aria-pressed', isDark);
});
</script>
Key points:
classList.toggle('dark')returnstrueif the class is now present.- Storing the user’s choice in
localStorageguarantees the same theme on subsequent visits. - Updating
aria-pressed(oraria-checkedfor a checkbox) makes the control accessible.
Accessibility & Contrast Considerations
Switching to a dark palette is not just about inverting colors. Ensure sufficient contrast for readability:
- Follow WCAG AA minimum contrast ratios (4.5:1 for normal text, 3:1 for large text).
- Don’t rely solely on color to convey meaning – add icons, outlines, or text where appropriate.
- Make the toggle reachable by keyboard (e.g., a
<button>element) and give it a visible focus style.
Worked Example – What Happens When the User Clicks
- Initial load: The browser evaluates
@media (prefers-color-scheme: dark). If the OS is in dark mode, the variables in:rootget the dark values. - User clicks the button:
- JavaScript toggles the
.darkclass on<html>. - The cascade now prefers the values defined inside
.darkover those in:root. - All properties that use
var(--bg-color),var(--text-color), etc., update instantly without a page reload.
- JavaScript toggles the
- Preference saved: The script writes “dark” or “light” to
localStorage. On the next visit, the script reads that value and forces the matching class, overriding the system preference if needed.
Mini‑Exercise
Add a new custom property called --accent-color that is used for button backgrounds. Define a light value (#0066cc) and a dark value (#66aaff). Then update the button CSS to use the variable. Verify that the button changes color when you toggle the theme.
Solution
<style>
/* Existing variables … */
:root {
--bg-color: #ffffff;
--text-color: #111111;
--link-color: #0066cc;
--accent-color: #0066cc; /* Light accent */
}
.dark {
--bg-color: #111111;
--text-color: #eeeeee;
--link-color: #66aaff;
--accent-color: #66aaff; /* Dark accent */
}
button {
background: var(--accent-color);
color: var(--bg-color);
border: none;
padding: 0.5rem 1rem;
}
</style>
Key Takeaways
- CSS custom properties let you centralize color definitions.
- The
prefers-color-schememedia query gives a sensible default based on the user’s OS. - A single class toggle on
<html>can switch the entire UI between palettes. - Persist the choice with
localStorageand keep the control accessible (ARIA, keyboard focus). - Always verify contrast ratios to maintain readability in both modes.
Example
HTML Skeleton
The markup is deliberately minimal – a single button that controls the theme and a few elements to show the color changes.
<!DOCTYPE html>
<html lang="en" class="light">
<head>
<meta charset="UTF-8">
<title>Dark Mode Demo</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<button id="theme-toggle" aria-label="Toggle dark mode">🌙</button>
<h1>Hello, world!</h1>
<p>This is a light‑theme paragraph.</p>
</body>
</html>
CSS: Defining Light and Dark Palettes with Variables
All colors are expressed through custom properties (CSS variables). Two sets of variables are defined – one for the light theme (default) and one for the dark theme. By switching the class on the <html> element, the browser automatically picks the appropriate palette.
/* styles.css */
/* 1️⃣ Base variables – shared across both themes */
:root {
/* Text and background are defined per‑theme */
--bg-color: #ffffff;
--text-color: #222222;
--accent-color: #0066ff;
}
/* 2️⃣ Light‑theme overrides (applied when .light is present) */
html.light {
--bg-color: #ffffff;
--text-color: #222222;
--accent-color: #0066ff;
}
/* 3️⃣ Dark‑theme overrides (applied when .dark is present) */
html.dark {
--bg-color: #121212;
--text-color: #e0e0e0;
--accent-color: #66b2ff;
}
/* 4️⃣ Use the variables throughout the stylesheet */
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: system-ui, sans-serif;
margin: 2rem;
transition: background-color 0.3s ease, color 0.3s ease;
}
h1 {
color: var(--accent-color);
}
/* 5️⃣ Style the toggle button – keep it visible in both themes */
#theme-toggle {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-color);
}
JavaScript: Toggling the Theme
The script reads the current class on the <html> element, switches it, and stores the user’s choice in localStorage so the preference persists across page loads.
// script.js
const toggleBtn = document.getElementById('theme-toggle');
const rootEl = document.documentElement;
// Determine default theme using the prefers‑color‑scheme media query
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedTheme = localStorage.getItem('theme');
// Apply the saved or system‑default theme on page load
if (savedTheme) {
rootEl.classList.toggle('dark', savedTheme === 'dark');
rootEl.classList.toggle('light', savedTheme === 'light');
} else {
rootEl.classList.toggle('dark', prefersDark);
rootEl.classList.toggle('light', !prefersDark);
}
// Click handler – flip between .light and .dark
toggleBtn.addEventListener('click', () => {
const isDark = rootEl.classList.contains('dark');
rootEl.classList.toggle('dark', !isDark);
rootEl.classList.toggle('light', isDark);
localStorage.setItem('theme', !isDark ? 'dark' : 'light');
});
Don’t forget to load the script at the end of <body> or with the defer attribute.
Result: What You Should See
- Initial load (light theme): white background, dark gray text, blue accent on the heading.
- When the moon button is clicked:
- The
htmlelement gains thedarkclass and loseslight. - Background darkens to
#121212, text becomes light gray, and the accent color lightens. - The transition is smooth thanks to the
transitionproperties onbody.
- The
- Refresh the page – the chosen theme is remembered thanks to
localStorage. - If no explicit choice was saved, the OS‑level preference (via
prefers-color-scheme) decides the start theme.
Walkthrough of the Example
- Open
index.htmlin a browser. You see a light‑themed page with a moon button. - Open the developer console and inspect
document.documentElement.className. It should read"light"(or"dark"if your system prefers dark). - Click the button. Observe:
- The class toggles to
"dark". - All colors update instantly, matching the values defined under
html.dark.
- The class toggles to
- Refresh the page. Verify the class stays
"dark"– the script has loaded the saved value fromlocalStorage. - Open the page in an incognito window (no stored preference). Change your OS theme to dark (or use Chrome’s “Emulate CSS prefers‑color‑scheme”). The page should now start in dark mode without any clicks.
Exercise: Add a “Sun” Icon for Light Mode
Replace the static moon emoji with an icon that reflects the current mode.
- Modify the HTML to include two spans inside the button, one for the moon (🌙) and one for the sun (☀️).
- Update the JavaScript so after each toggle it adds
hiddento the icon that does not represent the active theme. - Style the
.hiddenclass todisplay:none;. - Test that clicking the button swaps the visible icon while also toggling the theme.
Solution
<!-- index.html – updated button -->
<button id="theme-toggle" aria-label="Toggle dark mode">
<span class="icon-moon">🌙</span>
<span class="icon-sun hidden">☀️</span>
</button>
// script.js – icon toggle
const moonIcon = document.querySelector('.icon-moon');
const sunIcon = document.querySelector('.icon-sun');
function updateIcons(isDark) {
moonIcon.classList.toggle('hidden', isDark);
sunIcon.classList.toggle('hidden', !isDark);
}
// Initial sync
updateIcons(rootEl.classList.contains('dark'));
toggleBtn.addEventListener('click', () => {
const isNowDark = !rootEl.classList.contains('dark');
rootEl.classList.toggle('dark', isNowDark);
rootEl.classList.toggle('light', !isNowDark);
localStorage.setItem('theme', isNowDark ? 'dark' : 'light');
updateIcons(isNowDark);
});
The result is a more intuitive UI: a moon when the page is light, a sun when it is dark.
Coding Challenge
In this challenge you will build a fully functional dark‑mode toggle that works across browsers, respects the user's system preference, and remembers the choice between visits. Your implementation should be clean, accessible, and rely only on CSS custom properties and a small amount of JavaScript.
What You Need to Deliver
- An HTML page containing at least a heading, some paragraph text, and a button that users will click to switch themes.
- A CSS file that defines CSS custom properties for both the light and dark palettes and uses the
prefers-color-schememedia query to set the default theme. - A JavaScript module that:
- Detects the saved theme in
localStorage(if any) and applies it on page load. - Falls back to the system preference when no stored value exists.
- Toggles a
darkclass on the<html>element when the button is activated. - Updates
localStoragewith the new choice. - Ensures the button is accessible: provide an ARIA label, make it focusable, and allow keyboard activation (Enter/Space).
- Detects the saved theme in
Detailed Requirements
-
CSS variables for both themes
Define a set of variables (e.g.,--bg-color,--text-color,--link-color) under a:rootselector for the light theme and under.dark(or:root.dark) for the dark theme. All page colors must reference these variables. -
System‑preference default
Use the@media (prefers-color-scheme: dark)query to set the.darkclass on<html>automatically when the user’s OS prefers dark mode, unless a stored preference overrides it. -
Toggle button
Add a button (or<button>element) that, when clicked, adds or removes thedarkclass on the<html>element. The button should display a clear visual cue (e.g., a sun/moon icon or text “Toggle Dark Mode”). -
Persistence with
localStorage
Store the current theme as a string value (e.g.,"light"or"dark") under a key like"theme". On page load, read this value and apply the corresponding class before any flashes of the wrong theme occur. -
Accessibility
The button must have:- An
aria-labelsuch as “Toggle dark mode”. - Keyboard focus styles.
- Support for Enter and Space activation (default for
<button>).
- An
Success Criteria (How It Will Be Evaluated)
- Visual consistency: All page elements change colour according to the defined variables when the theme switches.
- Initial theme respects system preference when no
localStorageentry exists. - Saved preference survives reloads: After toggling, refreshing the page keeps the chosen theme.
- Button is accessible: Screen‑reader tools announce the ARIA label, focus can be moved to the button via Tab, and pressing Enter or Space toggles the theme.
- No flash of unstyled content (FOUC): The correct theme is applied before the page becomes visible (e.g., by running the JavaScript early or using a small inline script).
Sample Boilerplate (You May Extend)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dark Mode Demo</title>
<link rel="stylesheet" href="styles.css">
<script defer src="theme-toggle.js"></script>
</head>
<body>
<header>
<h1>Welcome to My Site</h1;
<button id="theme-toggle" aria-label="Toggle dark mode">🌙</button>
</header>
<main>
<p>This paragraph will adapt to the chosen colour scheme.</p>
<a href="#">Sample link</a>
</main>
</body>
</html>
What the Page Should Look Like
When the page loads in a light‑mode environment, the background might be #ffffff and the text #222222. After clicking the toggle, the background should switch to a dark shade (e.g., #121212) and the text become light (#e0e0e0). The change should be instantaneous and persist after a refresh.
Testing Your Implementation
- Open the page on a system set to light mode. Verify the light theme appears.
- Click the toggle button. Verify colours invert to the dark palette.
- Refresh the page. Confirm the dark theme remains active.
- Close the browser, reopen, and check that the stored theme persists.
- Switch your OS to dark mode, clear
localStoragefor the page (e.g., via dev tools), reload, and ensure the page starts in dark mode automatically. - Navigate with the Tab key to the toggle button; ensure it receives focus and can be activated with Enter or Space. Use a screen reader to confirm the ARIA label is announced.
Requirements / Input‑Output Expectations
Functional Requirements
- Use only native HTML, CSS, and JavaScript—no third‑party libraries or frameworks.
- The page must be fully functional in the latest stable releases of Chrome, Firefox, and Edge.
- A single toggle button switches between light and dark themes instantly, without a full page reload.
- The chosen theme persists across browser sessions; revisiting the page automatically applies the last selected mode.
- The initial theme follows the user's operating‑system preference via the
prefers-color-schememedia query when no prior selection exists. - The toggle button must be keyboard accessible and convey its state to assistive technologies (e.g., ARIA attributes).
Input Specification
- User Interaction: A click (or keyboard activation) on the theme‑toggle button.
- Stored Preference: If a value exists in
localStorageunder the keytheme, its value ("light" or "dark") is read on page load. - System Preference: If no stored value is present, the browser’s
prefers-color-schememedia query is evaluated to determine the default theme.
Output Specification
- Visual Change: The CSS custom properties (variables) for colors are updated, instantly reflecting the selected theme across the page.
- Persisted State: The current theme string ("light" or "dark") is written to
localStorageunder the keytheme. - Accessible Indicator: The toggle button’s
aria-labelandaria-pressedattributes are updated to describe the new state (e.g., “Enable dark mode”,aria-pressed="true"when dark mode is active).
Worked Example
Assume a user opens the page for the first time on a device set to dark mode.
- On load, the script checks
localStorage– finds nothemeentry. - The script evaluates
window.matchMedia('(prefers-color-scheme: dark)'), which returnstrue. - The page applies the dark‑theme CSS variables, the toggle button shows a moon icon, and
aria-pressed="true"is set. - The user clicks the toggle button.
- The script switches the theme to “light”, updates all CSS variables, sets
localStorage.setItem('theme', 'light'), and changes the button’saria-labelto “Enable dark mode”.
Edge Cases & Validation
- If
localStorageis unavailable (e.g., private browsing), fall back to the system preference without persisting the choice. - If the stored value is corrupted (neither “light” nor “dark”), ignore it and recompute the theme using the media query.
- The toggle must remain operable with the Enter or Space keys when the button receives focus.
Sample Code Snippets
<!-- HTML button (placed in the header) -->
<button id="theme-toggle"
aria-label="Enable dark mode"
aria-pressed="false">
🌞
</button>
<!-- CSS custom properties for both themes -->
:root {
--bg-color: #ffffff;
--text-color: #222222;
}
[data-theme="dark"] {
--bg-color: #1e1e1e;
--text-color: #f1f1f1;
}
body {
background: var(--bg-color);
color: var(--text-color);
}
<!-- JavaScript logic (placed at the end of ) -->
<script>
(function () {
const THEME_KEY = 'theme';
const toggleBtn = document.getElementById('theme-toggle');
const root = document.documentElement;
// Determine initial theme
const savedTheme = localStorage.getItem(THEME_KEY);
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = (savedTheme === 'light' || savedTheme === 'dark')
? savedTheme
: (prefersDark ? 'dark' : 'light');
applyTheme(initialTheme);
// Toggle handler
toggleBtn.addEventListener('click', () => {
const newTheme = root.dataset.theme === 'dark' ? 'light' : 'dark';
applyTheme(newTheme);
// Persist selection
try { localStorage.setItem(THEME_KEY, newTheme); } catch (e) { /* ignore storage errors */ }
});
function applyTheme(theme) {
root.dataset.theme = theme;
const isDark = theme === 'dark';
toggleBtn.textContent = isDark ? '🌙' : '🌞';
toggleBtn.setAttribute('aria-pressed', isDark);
toggleBtn.setAttribute('aria-label', isDark ? 'Enable light mode' : 'Enable dark mode');
}
})();
</script>
Constraints
Design Constraints
- Do not use inline
styleattributes for theming; rely solely on CSS variables. - Keep JavaScript in a separate
script.jsfile. - Theme definitions must be declared in
:root(or a dedicated theme class) using custom properties – no hard‑coded colour values in component selectors. - All colour values that change between light and dark modes must reference a CSS variable (e.g.,
color: var(--text-color);). - Do not import external CSS or JavaScript frameworks; the solution should be pure HTML, CSS, and vanilla JavaScript.
- The toggle button must be keyboard‑accessible (focusable via
Tab) and include an ARIA label that updates to reflect the current theme. - Support the
prefers-color-schememedia query to set the initial theme without JavaScript. - Provide a graceful fallback for browsers that do not support CSS variables (e.g., using a static dark‑mode stylesheet that is ignored when variables are available).
Examples of Constraint Compliance
Correct CSS variable usage (placed in styles.css):
<style>
:root {
--bg-color: #ffffff;
--text-color: #222222;
}
/* Dark theme overrides */
.dark-theme {
--bg-color: #1e1e1e;
--text-color: #e0e0e0;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
}
</style>
Correct JavaScript placement (in script.js):
document.addEventListener('DOMContentLoaded', () => {
const toggle = document.getElementById('theme-toggle');
// Set ARIA label based on current theme
const updateAria = () => {
const isDark = document.documentElement.classList.contains('dark-theme');
toggle.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode');
};
// Initialise based on system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark-theme');
}
// Click handler
toggle.addEventListener('click', () => {
document.documentElement.classList.toggle('dark-theme');
updateAria();
});
updateAria();
});
Exercise: Spot the Violations
Examine the following fragment and list any constraints it breaks.
<button id="theme-toggle" style="background:#000; color:#fff">Toggle</button>
<script>
// Inline script uses a hard‑coded colour
document.getElementById('theme-toggle').onclick = function() {
document.body.style.background = '#000';
document.body.style.color = '#fff';
};
</script>
Solution:
- Uses inline
styleattributes for theming – violates the “no inline style” rule. - JavaScript is embedded directly in the HTML instead of a separate
script.jsfile. - Hard‑coded colour values in JavaScript bypass CSS variables, breaking the variable‑only theming constraint.
- The button lacks an ARIA label and is not guaranteed to be keyboard‑focusable.
Verification Checklist
- [ ] All colour values in CSS are expressed with
var(--…). - [ ] No
styleattributes are used for theming. - [ ] JavaScript resides in
script.jsand is linked via<script src="script.js"></script>. - [ ] The toggle button has
tabindex="0"(or is a native focusable element) and an up‑to‑datearia-label. - [ ] The page respects
prefers-color-schemeon first load without any script intervention. - [ ] A fallback stylesheet or default variable values are provided for browsers lacking CSS variable support.
Hints
Managing CSS Variables
-
Define the light theme variables in
:root:
This makes the variables available to the entire document by default.:root { --bg-color: #ffffff; --text-color: #222222; --link-color: #0066cc; } -
Define the dark theme overrides inside a class (e.g.,
.dark) that you add to the<html>element:
By toggling the.dark { --bg-color: #121212; --text-color: #e0e0e0; --link-color: #bb86fc; }.darkclass you instantly switch all colors without rewriting individual rules. -
Use the variables in your stylesheet:
This keeps the CSS DRY and ensures every element updates automatically.body { background-color: var(--bg-color); color: var(--text-color); } a { color: var(--link-color); }
Persisting the User’s Choice
-
Read the saved theme from
localStoragewhen the page loads:
This restores the user’s preference across visits.const savedTheme = localStorage.getItem('theme'); // “light” or “dark” if (savedTheme) { document.documentElement.classList.toggle('dark', savedTheme === 'dark'); } -
When the toggle button is clicked, update both the class and
localStorage:
Keeping the storage write in the same function guarantees the UI and storage stay in sync.function toggleTheme() { const isDark = document.documentElement.classList.toggle('dark'); localStorage.setItem('theme', isDark ? 'dark' : 'light'); updateButtonLabel(isDark); } -
Fallback to the OS preference if nothing is stored:
This respects theif (!savedTheme) { const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; document.documentElement.classList.toggle('dark', prefersDark); }prefers-color-schememedia query for first‑time visitors.
Accessibility & ARIA
-
Update the button’s
aria-labelto announce the next mode:
Screen readers will read “Switch to dark mode” when the page is in light mode, guiding users clearly.function updateButtonLabel(isDark) { const btn = document.getElementById('theme-toggle'); btn.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode'); } -
Use a semantic button element (not a link) and include an accessible name:
The visual icon is decorative; the aria label conveys purpose.<button id="theme-toggle" aria-label="Switch to dark mode"> 🌙 </button> -
Maintain sufficient color contrast in both themes: Verify that the combination of
--bg-colorand--text-colormeets the WCAG AA ratio (≥4.5:1). Tools like Chrome DevTools’ “Contrast” audit can help.
Common Pitfalls to Watch Out For
- Forgetting to add the
.darkclass to the<html>element (or<body>)—the CSS variables won’t change. - Overriding a variable locally (e.g., setting
background-colorwith a fixed hex value) which prevents the theme switch from affecting that element. - Not updating the ARIA label after toggling, leaving assistive technology with stale information.
- Storing the theme as “dark-mode” or any other string—keep it simple (“dark” or “light”) so the read‑back logic stays straightforward.
Mini‑Exercise for Practice
Try adding a third “sepia” theme without changing the JavaScript logic. Implement it by:
- Creating a
.sepiaclass that sets new variable values (e.g., a warm background). - Adding a second toggle control that cycles through
light → dark → sepia → light. - Persisting the current mode in
localStorageusing the samethemekey.
When you’re done, the page should remember the last selected theme, and the button’s ARIA label should always describe the next mode in the cycle.
Solution Walkthrough
Step 1: Define the colour palette with CSS custom properties
All colours are stored as -- variables on the :root selector. The light theme values are the defaults; a .dark class overrides just the variables that need to change.
<style>
:root {
/* Light theme defaults */
--bg-color: #ffffff;
--text-color: #222222;
--link-color: #0066cc;
--border-color: #e0e0e0;
}
.dark {
/* Dark theme overrides */
--bg-color: #121212;
--text-color: #e0e0e0;
--link-color: #58a6ff;
--border-color: #333333;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
font-family: system-ui, sans-serif;
transition: background-color 0.3s ease, color 0.3s ease;
}
a {
color: var(--link-color);
}
hr {
border-color: var(--border-color);
}
</style>
Step 2: Apply the palette throughout the page
Whenever you need a colour, reference its variable with var(--name). This keeps the markup clean and makes the theme switch instantaneous because only the variable values change.
<header>
<h1>My Site</h1>
<button id="theme-toggle" aria-label="Activate dark mode">🌙</button>
</header>
<main>
<p>Welcome to the example page. The theme adapts to user preference.</p>
<a href="#">Read more</a>
</main>
Step 3: Detect the OS‑level colour‑scheme preference
The @media (prefers-color-scheme: dark) query lets the browser tell us when the user has requested a dark interface. If no explicit preference is stored in localStorage, we add the .dark class to <html> so the page starts in the correct mode.
<script>
(function() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Decide which class to apply before the page paints
if (stored === 'dark' || (!stored && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
</script>
Step 4: Build an accessible toggle button
The button uses aria-label to announce its purpose to screen readers and swaps its label/content depending on the current theme.
<button id="theme-toggle"
aria-label="Activate dark mode"
title="Toggle dark / light theme">
🌙
</button>
Visually the button can display a sun/moon emoji, an SVG icon, or any design you prefer; the ARIA label is what matters for accessibility.
Step 5: Write the JavaScript that toggles the theme
The script is deliberately tiny—only a few lines—but it covers three responsibilities:
- Toggle the
.darkclass on the root element. - Persist the new preference in
localStorage. - Update the button’s ARIA label and visible icon.
<script defer>
const btn = document.getElementById('theme-toggle');
const root = document.documentElement;
function setButtonLabel(isDark) {
btn.textContent = isDark ? '☀️' : '🌙';
btn.setAttribute('aria-label', isDark ? 'Activate light mode' : 'Activate dark mode');
}
function toggleTheme() {
const isDark = root.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
setButtonLabel(isDark);
}
// Initialise button label according to the starting state
setButtonLabel(root.classList.contains('dark'));
btn.addEventListener('click', toggleTheme);
</script>
Step 6: Ensure the preferred theme is applied before the first paint
Placing the small “pre‑load” script (the IIFE from Step 3) inside the <head> or loading it with defer guarantees the correct class is added before the browser renders the page. This eliminates a flash of the wrong theme (commonly called FOUC – Flash Of Unstyled Content).
Step 7: Verify the outcome
When you open the page you should see:
- The background colour, text colour, and link colour matching the light palette.
- If your OS is set to dark mode and you have no stored preference, the page starts in dark mode automatically.
- Clicking the toggle button instantly swaps the colour palette, updates the button icon (🌙 ↔ ☀️), and saves the choice.
- Reloading the page preserves the last chosen theme thanks to
localStorage. - The button’s
aria-labelcorrectly reflects the next action (e.g., “Activate light mode” when the page is dark).
Full Working Example
Copy the snippet below into an index.html file and open it in a browser to see the toggle 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 Mode Toggle Demo</title>
<style>
:root {
--bg-color: #ffffff;
--text-color: #222222;
--link-color: #0066cc;
--border-color: #e0e0e0;
}
.dark {
--bg-color: #121212;
--text-color: #e0e0e0;
--link-color: #58a6ff;
--border-color: #333333;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: system-ui, sans-serif;
margin: 0;
padding: 2rem;
transition: background-color 0.3s, color 0.3s;
}
a { color: var(--link-color); }
header { display: flex; justify-content: space-between; align-items: center; }
button { background: none; border: none; font-size: 1.5rem; cursor: pointer; }
hr { border: 0; border-top: 1px solid var(--border-color); margin: 2rem 0; }
</style>
<script>
// Apply stored or OS‑level preference before the page paints
(function() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (!stored && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
<body>
<header>
<h1>Demo Site</h1>
<button id="theme-toggle" aria-label="Activate dark mode">🌙</button>
</header>
<main>
<p>This page demonstrates a lightweight, accessible dark‑mode toggle built with CSS variables and a few lines of JavaScript.</p>
<a href="#">Learn more</a>
<hr>
<p>Resize the window or change your OS theme to see the automatic detection in action.</p>
</main>
<script defer>
const btn = document.getElementById('theme-toggle');
const root = document.documentElement;
function setButtonLabel(isDark) {
btn.textContent = isDark ? '☀️' : '🌙';
btn.setAttribute('aria-label', isDark ? 'Activate light mode' : 'Activate dark mode');
}
function toggleTheme() {
const isDark = root.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
setButtonLabel(isDark);
}
setButtonLabel(root.classList.contains('dark'));
btn.addEventListener('click', toggleTheme);
</script>
</body>
</html>
Common pitfalls & how to avoid them
- Forgetting to add the transition. Without
transitionthe colour swap is abrupt, which can feel jarring. Include it onbody(or any element that uses the variables). - Using
document.bodyinstead ofdocument.documentElement. The CSS variables live on:root, so toggling the class on<html>guarantees every variable updates. - Not updating the ARIA label. Screen‑reader users rely on the label to know what the button will do; always keep it in sync with the visual icon.
- Running the persistence script after the DOM loads. Doing so can cause a flash of the wrong theme. The early‑run IIFE solves this.
Recap
By separating colour definitions into CSS custom properties, leveraging the prefers-color-scheme media query for a sensible default, and persisting the user’s choice with a minimal JavaScript toggle, we achieve an accessible, performant dark‑mode solution that works across browsers without any heavy frameworks.
Starter Code
File Structure
Place the three files in the same folder (e.g., dark‑mode‑demo/) so they can reference each other without needing a server configuration.
index.html– markup and the toggle button.styles.css– CSS custom properties that define light and dark palettes.script.js– JavaScript that reads/writes the theme preference and updates the UI.
HTML – index.html
This is the minimal markup needed for the demo. The button uses aria‑label for screen‑reader accessibility, and the script is loaded with defer so the DOM is ready when the code runs.
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dark Mode Toggle</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<button id="theme-toggle" aria-label="Toggle dark mode">🌙</button>
<h1>Welcome to Dark Mode Demo</h1>
<p>Click the moon icon to switch themes.</p>
<script src="script.js" defer></script>
</body>
</html>
CSS – styles.css
The stylesheet defines three custom properties for background, text, and link colors. The .dark class on the <html> element overrides those properties. All other rules reference the variables, making the theme swap instantaneous.
/* styles.css */
:root {
/* Light theme defaults */
--bg-color: #ffffff;
--text-color: #222222;
--link-color: #0066cc;
}
/* Dark theme overrides */
.dark {
--bg-color: #121212;
--text-color: #e0e0e0;
--link-color: #bb86fc;
}
/* Apply the custom properties */
body {
background: var(--bg-color);
color: var(--text-color);
font-family: Arial, sans-serif;
margin: 2rem;
}
a {
color: var(--link-color);
}
/* Simple styling for the toggle button */
#theme-toggle {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
}
JavaScript – script.js
This self‑executing function handles three tasks:
- Read a persisted theme from
localStorage. - Fall back to the operating system’s
prefers-color-schememedia query when no preference exists. - Attach a click listener to the button that toggles the
.darkclass, updates storage, and changes the button’s label and icon.
// script.js
(function() {
const STORAGE_KEY = 'theme'; // Key used in localStorage
const htmlEl = document.documentElement; // element
const toggleBtn = document.getElementById('theme-toggle');
// -----------------------------------------------------------------
// 1️⃣ Apply stored theme or respect user's OS preference
// -----------------------------------------------------------------
const storedTheme = localStorage.getItem(STORAGE_KEY);
if (storedTheme) {
// storedTheme is either "dark" or "light"
htmlEl.classList.toggle('dark', storedTheme === 'dark');
updateButtonLabel(storedTheme);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
// No stored value → use system dark mode
htmlEl.classList.add('dark');
localStorage.setItem(STORAGE_KEY, 'dark');
updateButtonLabel('dark');
} else {
// System prefers light (or no preference)
updateButtonLabel('light');
}
// -----------------------------------------------------------------
// 2️⃣ Click handler – switch between themes
// -----------------------------------------------------------------
toggleBtn.addEventListener('click', () => {
const isDark = htmlEl.classList.toggle('dark'); // toggles class and returns new state
const newTheme = isDark ? 'dark' : 'light';
localStorage.setItem(STORAGE_KEY, newTheme);
updateButtonLabel(newTheme);
});
// -----------------------------------------------------------------
// 3️⃣ Update button icon & aria‑label for accessibility
// -----------------------------------------------------------------
function updateButtonLabel(theme) {
const label = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
const icon = theme === 'dark' ? '☀️' : '🌙';
toggleBtn.setAttribute('aria-label', label);
toggleBtn.textContent = icon;
}
})();
Testing the Starter Code
- Open
index.htmlin a modern browser (Chrome, Firefox, Edge, Safari). - Observe the page rendered with a light background and dark text.
- Click the moon (🌙) button. The background should turn dark, text become light, and the button should change to a sun (☀️) with an aria‑label of “Switch to light mode”.
- Refresh the page. The chosen theme persists because it was saved to
localStorage. - Open the page in a new incognito/private window (no stored theme). If your OS is set to dark mode, the page loads dark automatically; otherwise it starts light.
Common Pitfalls & How to Fix Them
- Files not found: Verify that
styles.cssandscript.jsare in the same directory asindex.html. A 404 console error indicates a path problem. - Button not responding: Ensure the script tag includes
defer(or place the script just before</body>) so the DOM element exists when the script runs. - Color contrast issues: The demo uses WCAG‑AA compliant contrasts, but if you replace colors, test them with tools like WebAIM Contrast Checker.
Next Steps (Optional)
If you want to experiment further, try adding a transition effect to make the background color change smoothly:
/* Add to styles.css */
html {
transition: background-color 0.3s ease, color 0.3s ease;
}
You can also store the theme in a cookie for older browsers, or expose the toggle via a keyboard shortcut for enhanced accessibility.
Stretch Goals
- Add a smooth CSS transition for background and text colors.
- Include additional UI elements (cards, inputs) that also respect the theme variables.
- Provide a dropdown that lets the user choose “light”, “dark”, or “system” explicitly.
Stretch Goal 1 – Smooth Theme Transitions
When the user flips the toggle, the change feels abrupt if the colors jump instantly. Adding a CSS transition makes the switch elegant and improves perceived performance.
How to implement:
/* Add these rules to your style sheet (e.g., styles.css) */
:root,
html.dark {
/* All custom properties are already defined here */
}
/* Apply the transition to any element that uses the colour variables */
body,
button,
input,
.card {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
Only the properties that reference a CSS variable need a transition. background-color and color are the most common, but you can also transition border-color, box-shadow, etc.
Expected result: Clicking the toggle fades the background from white to the dark colour (and vice‑versa) over roughly 0.3 seconds. The same effect applies to any element that inherits the colour variables, providing a cohesive visual experience.
Stretch Goal 2 – Theme‑aware UI Components
So far the tutorial only changes the page background and text colour. Real‑world sites usually have cards, form fields, and other components that should adapt as well.
Step 1 – Define extra custom properties. Extend :root and html.dark with variables for cards, inputs and borders.
/* style.css – extended variable set */
:root {
--bg-page: #ffffff;
--text-primary: #222222;
/* New UI variables */
--card-bg: #f9f9f9;
--card-border: #e0e0e0;
--input-bg: #ffffff;
--input-border: #cccccc;
--input-text: #000000;
}
html.dark {
--bg-page: #121212;
--text-primary: #e0e0e0;
--card-bg: #1e1e1e;
--card-border: #333333;
--input-bg: #2c2c2c;
--input-border: #555555;
--input-text: #e0e0e0;
}
Step 2 – Apply the variables to markup.
<section class="card">
<h2>Profile</h2>
<p>A short bio goes here.</p>
<label for="email">Email</label>
<input type="email" id="email" placeholder="you@example.com">
</section>
Corresponding CSS:
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
input {
background: var(--input-bg);
color: var(--input-text);
border: 1px solid var(--input-border);
padding: 0.5rem;
border-radius: 3px;
}
Because the variables are swapped when the dark class is added to <html>, the cards and inputs automatically follow the theme without extra JavaScript.
Stretch Goal 3 – Explicit Theme Selector (Light | Dark | System)
Some users prefer to override the operating‑system preference. A <select> element can let them choose “light”, “dark”, or “system”. The script must handle three states:
- light – always apply the light theme.
- dark – always apply the dark theme.
- system – follow
prefers-color-schemeand update automatically when the OS setting changes.
HTML markup
<label for="theme-select">Theme:</label>
<select id="theme-select">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
JavaScript logic
The existing toggle script can be extended. Below is a concise implementation that re‑uses the same STORAGE_KEY (“theme”). When “system” is selected we remove any explicit class from <html> and rely on the media query.
// script.js – extended version with dropdown
(function() {
const STORAGE_KEY = 'theme';
const htmlEl = document.documentElement;
const toggleBtn = document.getElementById('theme-toggle');
const selectEl = document.getElementById('theme-select');
// Helper: apply a theme (light|dark|system)
function applyTheme(theme) {
if (theme === 'dark') {
htmlEl.classList.add('dark');
} else if (theme === 'light') {
htmlEl.classList.remove('dark');
} else { // system
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
htmlEl.classList.toggle('dark', prefersDark);
}
updateButtonLabel();
selectEl.value = theme;
}
// Initialise
const saved = localStorage.getItem(STORAGE_KEY) || 'system';
applyTheme(saved);
// Keep "system" in sync with OS changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', e => {
if (localStorage.getItem(STORAGE_KEY) === 'system') {
applyTheme('system');
}
});
// Toggle button still works – it flips between light/dark and updates storage
toggleBtn.addEventListener('click', () => {
const current = localStorage.getItem(STORAGE_KEY) || 'system';
const newTheme = (current === 'dark') ? 'light' : 'dark';
localStorage.setItem(STORAGE_KEY, newTheme);
applyTheme(newTheme);
});
// Dropdown change handler
selectEl.addEventListener('change', () => {
const chosen = selectEl.value; // "light", "dark", or "system"
localStorage.setItem(STORAGE_KEY, chosen);
applyTheme(chosen);
});
function updateButtonLabel() {
const isDark = htmlEl.classList.contains('dark');
const label = isDark ? 'Switch to light mode' : 'Switch to dark mode';
const icon = isDark ? '☀️' : '🌙';
toggleBtn.setAttribute('aria-label', label);
toggleBtn.textContent = icon;
}
})();
Accessibility notes
- Use
aria-labelon the toggle button (already done) to convey its purpose. - Associate the
<select>with its<label>usingfor/idattributes. - Ensure sufficient colour contrast in both themes; WCAG AA requires a contrast ratio of at least 4.5:1 for normal text.
Testing the dropdown
- Select “dark”. The page should instantly acquire the dark background, and the toggle button now shows the sun icon.
- Refresh the page – the dark theme persists because the choice is stored in
localStorage. - Choose “system”. The UI follows your OS setting. If you change the OS theme while the page is open, the page should adapt automatically (thanks to the
mediaQuerylistener). - Pick “light”. The light theme appears, and the toggle button switches to the moon icon.
Recap of Stretch Goals
- Smooth transitions: add a
transitionrule to fade colours. - Theme‑aware components: define extra CSS variables for cards, inputs, etc., and use them in component styles.
- Explicit selector: implement a
<select>that lets users force light, dark, or follow the system; keep state inlocalStorageand listen for OS changes.
Completing these extensions turns a simple toggle into a production‑ready theming solution that feels polished, works across a variety of UI elements, and respects user preferences.