Dark mode is no longer a nice-to-have. Visitors expect it, accessibility guidelines encourage it, and modern browsers make it easier than ever to implement. In this tutorial, we will walk through how to add dark mode to a website using CSS variables and JavaScript, with zero framework dependencies. The result will respect the user’s system preference, remember their choice with localStorage, and stay fully accessible.
Unlike many tutorials that simply toggle a class, we will build a complete and production-ready solution that handles edge cases like flash of incorrect theme (FOIT), keyboard accessibility, and the new color-scheme property.
Why Use CSS Variables for Dark Mode?
CSS Custom Properties (also called CSS variables) are the cleanest way to manage theming. Instead of duplicating your stylesheet or overriding dozens of properties, you define your colors once and switch them at the root level.
- Maintainability: change a color in one place
- Performance: no extra stylesheets to load
- Flexibility: works with any HTML structure
- Future-proof: pairs perfectly with
prefers-color-schemeand the newlight-dark()function

Step 1: Define Your Color Tokens with CSS Variables
Start by declaring your light theme variables on the :root selector, then override them inside a [data-theme="dark"] attribute selector. Using a data attribute is more flexible than a class because it can hold multiple theme values later (high contrast, sepia, etc.).
:root {
color-scheme: light dark;
--bg-color: #ffffff;
--text-color: #1a1a1a;
--accent-color: #0066ff;
--border-color: #e5e5e5;
--card-bg: #f7f7f7;
}
[data-theme="dark"] {
--bg-color: #0f0f10;
--text-color: #f1f1f1;
--accent-color: #4d9fff;
--border-color: #2a2a2a;
--card-bg: #1a1a1c;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.2s ease, color 0.2s ease;
}
The color-scheme property tells the browser to render native UI elements (scrollbars, form controls, default backgrounds) in the appropriate scheme. This single line prevents many subtle visual bugs.
Step 2: Respect the User’s System Preference
Many users already configured their OS to use dark mode. Honoring that preference on first visit is a basic accessibility requirement. We use the prefers-color-scheme media query, but we apply it through JavaScript so we can still allow manual overrides.
const getPreferredTheme = () => {
const saved = localStorage.getItem('theme');
if (saved) return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
};
Step 3: Prevent the Flash of Incorrect Theme
One of the most common mistakes is loading the theme script at the bottom of the page. This causes a brief flash where the wrong theme is shown. The fix is to apply the theme as early as possible, ideally inline in the <head>.
<head>
<script>
(function() {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
This blocking script runs before any rendering, eliminating the flash entirely.
Step 4: Build the Toggle Button
The toggle should be accessible to keyboard users and screen readers. We use a real <button> element with an aria-pressed attribute that reflects the current state.
<button id="theme-toggle" type="button" aria-pressed="false" aria-label="Toggle dark mode">
<span class="icon-light" aria-hidden="true">☀</span>
<span class="icon-dark" aria-hidden="true">☾</span>
</button>
Step 5: Wire Up the JavaScript
The script handles the click, updates the DOM attribute, persists the choice in localStorage, and updates the ARIA state.
const toggle = document.getElementById('theme-toggle');
const applyTheme = (theme) => {
document.documentElement.setAttribute('data-theme', theme);
toggle.setAttribute('aria-pressed', theme === 'dark');
localStorage.setItem('theme', theme);
};
toggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
});
// Initialize aria-pressed on load
applyTheme(document.documentElement.getAttribute('data-theme'));
// Listen to system changes if user has not manually picked a theme
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
applyTheme(e.matches ? 'dark' : 'light');
}
});
Step 6: Style the Toggle Icons
Show only the relevant icon based on the current theme using attribute selectors.
#theme-toggle {
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
cursor: pointer;
}
[data-theme="light"] #theme-toggle .icon-dark { display: none; }
[data-theme="dark"] #theme-toggle .icon-light { display: none; }
Comparison: Different Approaches to Dark Mode
| Approach | Pros | Cons |
|---|---|---|
Pure prefers-color-scheme |
Zero JS, automatic | No manual override |
| CSS variables + JS toggle (this guide) | Flexible, persistent, accessible | Requires a small script |
light-dark() function |
Concise, modern syntax | Newer browsers only, no manual toggle out of the box |
| Class-based toggle | Simple | Less semantic than data attributes |
Bonus: Using the Modern light-dark() Function
If you only need to support up-to-date browsers, the light-dark() CSS function (now widely supported in 2026) lets you skip the override block entirely.
:root {
color-scheme: light dark;
--bg-color: light-dark(#ffffff, #0f0f10);
--text-color: light-dark(#1a1a1a, #f1f1f1);
}
You can still combine it with the data attribute approach by setting color-scheme dynamically through JavaScript when the user toggles.
Accessibility Checklist
- Use a real
<button>, not a<div>with a click handler - Provide an
aria-labelor visible text - Update
aria-pressedto reflect the current state - Maintain a contrast ratio of at least 4.5:1 in both themes
- Test the toggle with keyboard navigation (Tab + Enter)
- Honor
prefers-reduced-motionif you animate transitions
Common Pitfalls to Avoid
- Forgetting
color-scheme: native form controls will look out of place - Loading the theme script late: causes a visible flash
- Hardcoding colors in components: defeats the purpose of variables
- Using only system preference: users want manual control
- Skipping localStorage: users get frustrated when their choice is forgotten
FAQ
Do I need a JavaScript framework to build a dark mode toggle?
No. As shown in this tutorial, plain JavaScript and CSS variables are enough. Frameworks add complexity without benefits for a feature this small.
Should I use a class or a data attribute to switch themes?
Data attributes like data-theme are preferred because they can hold multiple values (light, dark, sepia, high-contrast) and are semantically clearer than stacking classes.
How do I prevent the flash of incorrect theme?
Inline a small synchronous script in the <head> that reads from localStorage and sets the data-theme attribute before the page renders.
Does this approach work with images?
Yes. You can swap images per theme using the <picture> element with media="(prefers-color-scheme: dark)", or by setting filter: brightness(0.85) on images in dark mode.
Is the light-dark() function ready for production?
As of 2026, light-dark() is supported in all major evergreen browsers. For sites that need to support older versions, the CSS variables override approach in this guide remains the safest choice.
Can I animate the theme change?
Yes, by adding a transition on background-color and color. Keep it short (around 200ms) and respect the prefers-reduced-motion media query.
Wrapping Up
You now have a complete, accessible, framework-free dark mode implementation. It respects the user’s system preference, persists their explicit choice, prevents flash on load, and uses modern CSS features. Drop the snippets into your project, adjust the color tokens to match your brand, and your visitors will get a polished theming experience right out of the box.

