Let’s talk about one of the most requested features on the modern web: Dark Mode. It is a cool user preference to add, but the way it gets implemented in production is often terrifying.
Open up a standard UI library or theme toggler package on npm. Look at the dependency graph and check out the bundle size. Why are we installing a 40kb+ third-party package just to toggle a couple of hex codes? It’s another performance crime.
These scripts have to download and parse before the browser even knows what color background to paint. The result? A horrible user experience where your First Contentful Paint (FCP) tanks, or your users get blinded by a sudden flash of bright white light before the dark theme finally kicks in.
At Llama Dev Builds, we believe in a better approach.
You don't need a massive state management ecosystem or a bulky theme
provider framework to swap colors. The native web platform already
gives us everything we need. By pairing CSS custom properties with a
tiny splash of Vanilla JavaScript and localStorage, we
can build a lightning-fast, persistent dark mode toggle that loads
instantly and costs zero bytes in external
dependencies.
Let's build a dark mode toggle in vanilla JS, the lighter way.
The Semantic HTML Button
The foundation of a performant toggle isn't JavaScript. It’s
semantic HTML. It is incredibly tempting to grab a generic
<div>, slap an onClick handler on
it, style it to look like a slider, and call it a day.
Don't do it.
When you build a toggle out of a <div>, you break
the web for anyone who isn't using a mouse. A generic
<div> cannot be reached using the
Tab key by default. It doesn't respond when someone
presses Space or Enter. To fix a broken
<div> toggle, you would have to manually inject
tabindex="0", code your own keyboard event listeners,
and fake the button behavior.
Why write extra JavaScript to fix a problem you created yourself?
Instead, let’s use the native platform and keep our codebase incredibly lightweight:
<!-- The Semantic Toggle Button -->
<button id="theme-toggle" aria-label="Toggle Dark Mode" class="theme-btn">
Toggle Theme
</button>
Native Benefits for Free
By choosing a true HTML <button> element, the
browser automatically grants us perfect keyboard accessibility. Any
user navigating your site via keyboard can natively tab straight to
this button and activate it with a quick tap of
Space or Enter. No extra code required.
Giving the Button a Voice: aria-label
Right now, our button contains the text Toggle Theme.
But in a real-world layout, you will likely replace that text with a
sleek SVG icon—like a sun or a moon.
If you delete the text and just leave an icon, a screen reader will
encounter the button and announce absolutely nothing to a visually
impaired user. By explicitly defining
aria-label="Toggle Dark Mode”, we ensure that assistive
technologies instantly announce the exact intent of the button,
regardless of what visual icon or text sits inside it. Furthermore,
clean ARIA attributes provide better contextual signals to search
engine crawlers, giving your technical SEO a subtle
boost.
The CSS Variable Architecture
Now that our HTML button is accessible, we need a scalable visual system to swap our colors. If you were using a heavy CSS-in-JS ecosystem, you might be tempted to handle this by passing an entirely new theme configuration through a complex JavaScript state object.
Instead, we are going to let the browser do the heavy lifting natively using CSS custom properties (variables).
The strategy is beautifully simple: we define our global color
variables on the :root for our default light theme.
Then, we declare a .dark-mode class that simply
reassigns those exact same variable names to dark-themed colors.
Here is the code structure:
/* 1. Default Light Theme Variables */
:root {
--bg-color: #FAFAFA;
--text-main: #333333;
--text-meta: #5C6682;
}
/* 2. Dark Theme Overrides */
body.dark-mode {
--bg-color: #1E1E24;
--text-main: #F4F4F4;
--text-meta: #8892B0;
}
/* 3. Apply Variables & Smooth Transition */
body {
background-color: var(--bg-color);
color: var(--text-main);
transition: background-color 0.3s ease, color 0.3s ease;
}
Why This Architecture Wins
Notice that we aren't writing duplicate CSS styles for every single
element on the page. We aren't creating a separate
.dark-mode-header or .dark-mode-text class
block.
By applying var(--bg-color) and
var(--text-main) directly to the
<body>, any elements nested inside it
automatically inherit those changing values. When JavaScript appends
the .dark-mode class to the body, the browser swaps out
the root variable values behind the scenes, and the entire page
instantly repaints its color scheme.
No Harsh Snapping: The Value of transition
If we dropped the variables into place without a transition, toggling the mode would cause the background and text colors to snap instantly. On a bright monitor, this harsh contrast shift can feel jarring to a user's eyes.
To fix this, we apply a subtle
transition: background-color 0.3s ease, color 0.3s ease;
to the body element. Now, when the classes change, the browser
handles a buttery-smooth fade between the light and
dark color spaces, giving your web app a highly polished,
professional finish without pulling in a bulky animation library.
Writing the Vanilla JS Engine
Now that our styling engine is built on CSS variables, we need a
lightweight way to toggle that .dark-mode class.
Instead of dragging in an external state management package, we are
going to write a tiny, high-performance script using pure vanilla
JavaScript.
Let's break down the logic piece by piece before we stitch the whole thing together.
Selecting the DOM Elements
First, we need to grab our accessible button from the Document Object Model (DOM) so our code can interact with it.
// Grab the button from the DOM
const themeToggleBtn = document.querySelector('#theme-toggle');
// Listen for a click event to toggle the class on the body
themeToggleBtn.addEventListener('click', () => {
document.body.classList.toggle('dark-mode');
});
Using document.querySelector, we target our specific
button ID #theme-toggle and assign it to a stable
variable. Next, we attach an event listener to listen for a
click interaction. Every single time a user clicks or
activates that button with their keyboard, our callback function
runs.
Toggling the CSS Class
Inside that click handler, we target
document.body.classList.toggle('dark-mode');.
The toggle() method is a built-in utility that acts
like a smart light switch. It reads the current class state of the
element. If the <body> does not have the
.dark-mode class, it appends it. If the
<body> already has the class, it instantly strips
it away.
This triggers the CSS variable reassignment we built in the previous step, resulting in a smooth color fade.
Saving User Preferences with localStorage
Right now, our toggle works perfectly, but there is a major problem: if a user clicks dark mode and refreshes the page, the browser loses that state and reverts back to the default light theme.
To fix this and provide a premium user experience, we need to
persist their choice. We can do this natively with the browser's
localStorage API.
themeToggleBtn.addEventListener('click', () => {
document.body.classList.toggle('dark-mode');
// Check if the body currently has the dark-mode class
const theme = document.body.classList.contains('dark-mode') ? 'dark' : 'light';
// Save that string to the browser's local storage
localStorage.setItem('theme', theme);
});
Here, we add a conditional check right after the toggle occurs. We
use a ternary operator to ask the DOM:
"Does the body element contain the class
dark-mode right now?"
If it does, we save the string 'dark' to a variable
named theme. If it doesn't, we save
'light'. Finally, we call
localStorage.setItem('theme', theme). This writes a
small key-value pair directly into the user's browser memory that
persists even if they close the tab or reboot their machine.
Intercepting System Preferences (The Pro Move)
Most basic tutorials stop after setting up an event listener and
writing to localStorage. But to build a truly
production-ready UI, you need to account for your user's operating
system configurations before they even click your toggle button.
If a user has set their OS to a system-wide dark mode, your web app should honor that choice out of the box.
To elevate our script, we are going to introduce
window.matchMedia('(prefers-color-scheme: dark)'). This
powerful native API allows JavaScript to read the user's OS-level
CSS media query preferences directly.
Here is the complete, production-grade script that links our event listeners, storage tracking, and system preference configurations together:
const themeToggleBtn = document.querySelector('#theme-toggle');
// 1. Check OS system preferences
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');
// 2. Check for saved user preference in localStorage
const currentTheme = localStorage.getItem('theme');
// 3. Determine if dark mode should be applied on load
if (currentTheme === 'dark' || (!currentTheme && prefersDarkScheme.matches)) {
document.body.classList.add('dark-mode');
}
// 4. Handle the button click, toggle, and save
themeToggleBtn.addEventListener('click', () => {
document.body.classList.toggle('dark-mode');
const theme = document.body.classList.contains('dark-mode') ? 'dark' : 'light';
localStorage.setItem('theme', theme);
});
Breaking Down the Load State Logic
Look closely at the condition in Step 3:
if (currentTheme === 'dark' || (!currentTheme && prefersDarkScheme.matches))
We're checking two critical things to determine if the
.dark-mode class should be added on load:
1. Did they explicitly choose a theme before? If
currentTheme === 'dark', we respect their previous
hardcoded preference regardless of what their system says.
2. Is it their first time visiting? If there is no
item saved in storage (!currentTheme), we fallback to
checking their operating system
(prefersDarkScheme.matches). If their device is set to
dark mode, we seamlessly apply the .dark-mode class
right away.
The Ultimate Pro-Move: Preventing the FOUC
Architectural Warning: If you place this script at the absolute bottom of your HTML document, your users will experience a Flash of Unstyled Content (FOUC). The browser will paint the light theme first, read the script, and then violently flash to dark mode.
To prevent this, take Steps 1 through 3 from the code block above
and place them inside an inline <script> tag
directly in the <head> of your HTML document.
This forces the browser to resolve the theme state
before it paints the <body>, ensuring a
flawless, flash-free user experience.
This architectural approach bridges the gap between clean engineering and exceptional UX. It values user choice, guarantees performance, and uses zero third-party packages to get the job done.
Conclusion
Adding a feature like dark mode shouldn't feel like
a heavy architectural burden. By stepping away from bulky
dependencies and leaning on native web tools like CSS custom
properties, localStorage, and matchMedia,
you create a system that is robust, maintainable, and
lightning-fast.
As developers, it is easy to fall into the habit of installing packages for every single feature request. But as you continue to build out your toolkit, remember that every third-party script you avoid loading is a direct win for your site's performance, stability, and security. Trust the native web platform—it is already capable of handling the heavy lifting.
Stop fighting heavy frameworks.
If you want to master vanilla web development and speed up your workflow, check out my premium, zero-bloat UI component kits.
Explore the UI Kits →