Want to stop your site from blinding users at night?
Dark mode is no longer a gimmick, it’s expected, and adding it keeps readers engaged.
In this guide you’ll build a lightweight dark mode toggle using CSS variables (custom properties) and a few lines of JavaScript.
You’ll set color tokens in :root, override them under data-theme=”dark”, flip the attribute with a button, and persist the choice in localStorage so the site remembers the user’s next visit.
By the end you’ll have a smooth, accessible toggle that applies instantly.
Building the Core Dark Mode Toggle Functionality

Dark mode toggle with CSS variables and JavaScript needs three coordinated pieces. You’ll define color tokens as CSS custom properties inside :root, reassign those tokens under a data-theme=”dark” selector, and use var() references everywhere so switching data-theme instantly applies the new palette. The entire approach avoids page reloads and keeps styling predictable.
Next, add a simple button in your HTML. A plain
Finally, a short JavaScript snippet manages state. It reads the saved theme from localStorage when the page loads, listens for clicks on the button, and writes back to localStorage whenever the user switches. This flow ensures your site remembers user preference across sessions without server config.
- Add minimal CSS variables to :root (e.g., –bg, –text).
:root { --bg: #ffffff; --text: #000000; }defines your default palette. - Create basic dark theme overrides under [data-theme=”dark”].
[data-theme="dark"] { --bg: #1a1a1a; --text: #e0e0e0; }reassigns tokens for dark mode. - Insert a simple toggle button into the HTML.
<button id="theme-toggle">Toggle Dark Mode</button>gives your users a clickable control. - Add JavaScript that switches the data-theme attribute.
document.documentElement.setAttribute('data-theme', 'dark')applies the dark palette instantly. - Save the chosen theme in localStorage.
localStorage.setItem('theme', 'dark')persists the choice so your page reopens with the same theme.
Structuring CSS Variables for a Clean Theme Architecture

Organize your CSS custom properties into logical groups so future changes stay manageable. Declare every color variable once in :root, then override the same variable names inside [data-theme=”dark”]. This pattern keeps your theme definitions isolated and prevents accidental duplication across selectors.
Group variables by purpose rather than component. When you separate background tokens from text tokens from accent tokens, you make it easy to audit contrast, swap entire palettes, or introduce a third theme later without hunting through scattered declarations.
- Background variables cover surface and page level fill colors (e.g., –bg, –surface).
- Text variables handle body copy, headings, and muted text (e.g., –text, –text-muted).
- Accent variables store primary and secondary brand colors (e.g., –accent, –accent-hover).
- Border variables control divider and outline colors (e.g., –border, –border-subtle).
- Surface variables apply to cards, modals, and raised elements (e.g., –surface-raised).
- Typography color variables define links and inline code (e.g., –link, –code-bg).
HTML Markup Essentials for a Theme Toggle Button

Use a standard
Wrap the button in a semantic container if you need positioning helpers, but keep the markup lean. The button’s only job is to signal a click. JavaScript will handle the logic and CSS will update the visuals when data-theme changes.
ARIA Labeling
Add aria-pressed to the button so screen readers announce the current state. Set aria-pressed=”false” on page load when light mode is active, then flip it to “true” when dark mode is on. This pattern tells assistive technology whether the toggle is currently engaged or not. <button id="theme-toggle" aria-pressed="false">Dark Mode</button> gives screen reader users clear feedback.
Writing the JavaScript to Switch Themes Smoothly

Attach a click event listener to your toggle button during page load. Inside the listener, read the current data-theme attribute from document.documentElement, decide the opposite theme, then set the new attribute with setAttribute. This approach keeps your code clear and avoids relying on classList when data-theme is the more semantic choice.
Initialize the theme before the browser paints anything visible. Place a small inline script at the top of your
, or load an external script with defer and read stored preference immediately. Reading localStorage and applying the data-theme attribute before CSS loads prevents a flash of the wrong theme when the page renders.When the user clicks the toggle, read the current theme value, switch it to the opposite choice, and update both the attribute and localStorage in one step. This synchronous update ensures the new theme applies instantly and persists for the next visit without waiting for network requests or complex state management.
Persisting Theme Choice Across Sessions

Store the active theme in localStorage so the user’s choice survives browser restarts and new tabs. Use a simple key like “theme” and store either “light” or “dark” as the value. On every page load, check localStorage first, fall back to system preference if nothing is stored, and apply the result before rendering.
Read the stored value with localStorage.getItem(‘theme’), then apply it directly to data-theme on the root element. When the user clicks the toggle, immediately write the new choice with localStorage.setItem(‘theme’, newValue). This client side persistence is fast and requires no server config.
| Method | Behavior |
|---|---|
| localStorage | Persists indefinitely across sessions; survives browser restarts; no server required. |
| cookies | Can be set with expiration dates; sent with every request; useful if server needs theme info. |
Improving UX with System Preference Detection and Smooth Transitions

Check the user’s operating system preference with the prefers-color-scheme media query when no theme is stored in localStorage. Wrap the check in JavaScript by calling window.matchMedia(‘(prefers-color-scheme: dark)’).matches, which returns true if the OS is set to dark mode. Use this value as your fallback initialization so the site respects user settings by default.
Add a brief CSS transition on color properties to smooth the switch between themes. Set transition: background-color 0.2s ease, color 0.2s ease on high level elements so colors fade instead of snapping. Keep the duration short. Anything over 300 milliseconds feels sluggish when toggling. Always respect prefers-reduced-motion by wrapping transitions in a media query that disables them when the user has requested reduced motion.
- System preference fallback reads prefers-color-scheme when localStorage is empty so first time visitors see the theme they expect.
- Adding transitions applies short color transitions (around 200ms) to avoid abrupt flashes when switching themes.
- Reducing layout shifts loads and applies theme data in before body renders to prevent visible flicker.
- Motion preferences wraps transitions in @media (prefers-reduced-motion: no-preference) so users with motion sensitivity skip animations.
Managing Accessibility and Contrast in Dark Mode

Dark themes require higher attention to contrast ratios because darker backgrounds can make text harder to read if your accent colors aren’t bright enough. Follow WCAG 2.1 Level AA guidelines, which require a 4.5:1 contrast ratio for normal text and 3:1 for large text. Test every text and background pairing in both light and dark modes to ensure neither theme drops below these thresholds.
Avoid pure black backgrounds in dark mode. Pure black (#000000) paired with white text creates harsh contrast that strains eyes during long reading sessions. Instead, use a dark gray like #1a1a1a or #121212 for backgrounds and slightly off white text like #e0e0e0 to soften the overall look while maintaining readability.
- Run automated contrast checks using browser devtools or online checkers to verify text to background ratios meet WCAG standards.
- Test with real content by checking contrast on actual headings, body copy, and link colors, not just sample swatches.
- Adjust accent colors because colors that work in light mode often fail in dark mode; brighten or desaturate accents to preserve visibility.
Component-Level Theming and Scoped CSS Variables

CSS variables inherit down the DOM tree, which means you can redefine a variable inside a component wrapper and every child will pick up the new value. Declare component specific overrides by scoping new variable assignments to a class or data attribute on the component container. This pattern lets you theme individual sections without touching global :root definitions.
For example, a card component might need a slightly lighter surface color in dark mode. Define –card-bg inside .card and reference it in the card’s background property. When [data-theme=”dark”] is active, override –card-bg inside .card under that selector so the card gets a unique dark surface while still inheriting global text and accent colors.
- Component overrides redefine variables inside component selectors to create local theme variations.
- Inheritance flow means child elements automatically pick up new variable values set on parent containers.
- Scoped adjustments use data attributes or classes on component wrappers to apply theme tweaks without editing global styles.
- Fallback strategy always provides a default value in var() calls so components degrade gracefully if a variable is missing.
Expanding Beyond Basics: Multiple Themes and Advanced Options

Design tokens extend the CSS variable approach by organizing color scales, spacing, and typography into a reusable system. Define a full palette of shades for each color (e.g., blue-100 through blue-900) and assign those shades to semantic tokens like –text, –surface, and –accent. This structure makes it simple to swap entire palettes when adding new themes or adjusting brand colors.
Supporting more than two themes is straightforward once your variable architecture is in place. Add additional [data-theme=”value”] selectors in your CSS, each overriding the same root variables with a new palette. Users can select from light, dark, high contrast, or any custom theme you define, and the same JavaScript toggle logic applies by cycling through the available data-theme values.
High contrast themes improve accessibility for users with low vision. Create a high contrast variant by defining strong foreground background pairings and eliminating subtle grays. Use data-theme=”high-contrast” to isolate these overrides and offer the mode as a separate toggle or combine it with dark mode by layering multiple data attributes.
Multi-Theme Architecture
Assign each theme a unique data-theme value like “light”, “dark”, “sepia”, or “midnight”. Store the active theme name in localStorage, then apply it to document.documentElement when the page loads. Update your toggle button to cycle through the available themes instead of just flipping between two states. Use an array in JavaScript to track theme names. const themes = ['light', 'dark', 'sepia'] works well, and increment an index on each click, wrapping back to zero when you reach the end. currentIndex = (currentIndex + 1) % themes.length keeps the loop running smoothly.
Final Words
You now have a minimal :root with –bg, –text and dark overrides, an accessible toggle button, and JavaScript that sets data-theme, reads system preference, and writes the choice to localStorage.
You also learned to group variables, scope component overrides, add smooth transitions, and check contrast so the theme stays usable and polished across sessions.
This guide showed how to implement a dark mode toggle using CSS variables and JavaScript in a compact, practical way. Try it in a small project, tweak colors, and enjoy the improved UX. You’ve got this.
FAQ
Q: How do you implement a dark mode toggle using CSS? / How to use dark mode in CSS?
A: Implementing a dark mode toggle with CSS means defining :root variables (like –bg, –text), adding [data-theme=”dark”] overrides, placing a toggle button, and using JavaScript to switch and save the data-theme.
Q: Is dark mode better for ADHD?
A: Dark mode being better for ADHD depends on the person: some benefit from reduced glare and visual clutter, others find contrast or readability worse—try both, tweak contrast, and pick what reduces distraction.
Q: Can I use CSS variables in JavaScript?
A: You can use CSS variables in JavaScript by reading them with getComputedStyle(element).getPropertyValue(‘–name’) and writing with element.style.setProperty(‘–name’, value), usually on document.documentElement for global themes.

