Managing Light/Dark Themes with JavaScript: Giving Users Full Control

I. HTML: Structuring and Optimizing for Light/Dark Themes
1. Adding a Manual Theme Toggle Button with Accessibility
When offering a button to switch themes, accessibility must be a top priority.
It’s essential to use a semantic element like <button>
and provide a clear label via the aria-label
attribute.
<button id="theme-toggle" aria-label="Activate dark mode">🌓</button>
The aria-label
attribute is crucial here: it enables assistive technologies (like screen readers) to clearly announce the intended action.
Without it, a visually impaired user would have no idea what the button does, especially if it only displays an icon.
By describing the action (“Switch theme,” “Activate dark mode,” etc.), you ensure an inclusive experience for all users.
2. Using the data-*
Attribute
A clean and scalable approach is to use a custom attribute like data-theme
on the html
(or body
) tag to reflect the active theme:
<html data-theme="light">
This attribute allows you to:
- Target conditional CSS styles (
[data-theme="dark"] { ... }
), - Control theme logic in JavaScript (read, toggle, etc.).
This concept primarily lays the foundation for the logic that JavaScript will use.
Therefore, it’s not necessary to add the data-theme
attribute manually in the HTML, it will be applied dynamically by the script.
3. Managing Images Based on the Theme
For visual elements like logos, it's common to display a light or dark version depending on the active theme.
However, unlike the approach described in the previous article, which relies solely on system preferences (prefers-color-scheme
),
here we manage themes dynamically with JavaScript.
This changes how we handle images.
Why not use <picture>
with media="(prefers-color-scheme: dark)"
The following method might seem natural, but it's not suitable in a manually controlled theme system:
<picture>
<source srcset="/images/logo-dark.png" media="(prefers-color-scheme: dark)">
<img src="/images/logo.png" alt="Site logo">
</picture>
The issue here is that the browser selects the image at page load based on system preferences. It doesn’t account for any dynamic changes made later via JavaScript.
As a result, if the user manually switches themes using a button, the image won’t update, the source was already chosen by the browser.
Solution: A single <img>
tag with a dedicated class
To handle the image dynamically, it’s better to use a standard <img>
tag with a specific class, like this:
<img class="has-dark" src="/images/logo.png" alt="Site logo">
JavaScript can then dynamically update the src
attribute when the theme switches to dark,
for example by appending -dark
to logo.png
so it loads the alternative file logo-dark.png
.
II. CSS: Using data-theme
for Theme Switching
To ensure smooth, maintainable, and manually controllable theme switching, it’s best to structure your styles around a central HTML attribute: data-theme
.
Applied to the <html>
tag, this attribute serves as the anchor point for all light/dark theme CSS logic.
It helps avoid duplicating styles and keeps your rules clean and centralized.
1. Manual Theming via data-theme
As discussed in the HTML section, if you want users to manually choose their theme using a toggle button, you simply apply a data-theme="light"
or data-theme="dark"
attribute to the <html>
element dynamically.
This allows you to control the overall interface colors using CSS variables, without needing to rewrite all your styles every time:
:root[data-theme="dark"] {
--bg-color: #121212;
--text-color: #f0f0f0;
}
This pattern makes theme switching very simple: JavaScript only needs to change the value of the data-theme
attribute,
and the styles will automatically update thanks to the CSS variables.
2. Avoiding Conflicts: Prioritizing Manual Theme Selection
In a project that combines both system detection (prefers-color-scheme
) and manual control (using data-theme
),
it’s important to give priority to the user’s explicit choice over automatic settings.
By default, if you use a media query like:
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #121212;
--text-color: #f0f0f0;
}
}
This media query will be evaluated by the browser as soon as the page loads, and it will apply even if the user later selects a theme using the JS toggle button.
To prevent this conflict, you can restrict the media query’s effect to cases where no explicit theme has been set by using the :not([data-theme])
selector:
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--bg-color: #121212;
--text-color: #f0f0f0;
}
}
In the context of this article, this fallback isn’t strictly necessary, because the data-theme
attribute is added on the very first visit via JavaScript, whether from a saved theme or a system preference detection.
However, adding this condition is still considered a best practice, as it helps handle cases where:
- JavaScript is disabled or not functioning,
- JavaScript loading is delayed,
- or an error prevents
data-theme
from being applied in time.
This improves the system’s robustness and ensures better accessibility from the very first milliseconds of rendering.
III. JavaScript: Managing Themes by Letting Users Choose
In this section, we’ll implement the full JavaScript logic that allows a website to:
- Apply a light or dark theme based on the user’s or system’s preference,
- Remember the user’s choice for future visits,
- Dynamically update site images according to the active theme.
The whole system relies on a simple principle: dynamically control the data-theme
attribute on the <html>
element, and adjust the rendering accordingly.
1. updateImagesByTheme()
: Syncing Images with the Theme
This function loops through all images marked with the has-dark
class and updates their src
based on the current theme:
function updateImagesByTheme() {
First, we check if the current theme is set to dark mode:
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
Then we select only the images that have a dark mode alternative (those with the has-dark
class), and loop through each one:
document.querySelectorAll('img.has-dark').forEach((img) => {
We retrieve the image’s original source, if it’s already stored in data-src
, we reuse it;
otherwise, we read the current src
attribute:
const originalSrc = img.dataset.src || img.getAttribute("src");
If not already done, we store the original src
in data-src
:
if (!img.dataset.src) img.dataset.src = originalSrc;
We then determine the appropriate src
based on the theme:
- In dark mode: replace
.ext
with-dark.ext
(e.g. image.jpg → image-dark.jpg) - In light mode: revert to the original image
img.src = isDark
? originalSrc.replace(/(\.\w+)$/, "-dark$1")
: img.dataset.src;
Complete function:
function updateImagesByTheme() {
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
document.querySelectorAll('img.has-dark').forEach((img) => {
const originalSrc = img.dataset.src || img.getAttribute("src");
if (!img.dataset.src) img.dataset.src = originalSrc;
img.src = isDark
? originalSrc.replace(/(\.\w+)$/, "-dark$1")
: img.dataset.src;
});
}
This method allows you to dynamically load an alternative image version without relying on <picture>
or CSS media queries.
2. applyTheme(theme)
: Switching Themes and Updating the Interface
This function applies a given theme ("light" or "dark") by updating the data-theme
attribute on the <html>
element,
then calls updateImagesByTheme()
so the images reflect the current theme:
function applyTheme(theme) {
Set the data-theme
attribute on the <html>
element (this affects global CSS):
document.documentElement.setAttribute('data-theme', theme);
Update the displayed images based on the selected theme:
updateImagesByTheme();
Dynamically update the content of the toggle button (🌞 or 🌙):
const toggleThemeButton = document.getElementById('theme-toggle');
toggleThemeButton.textContent = theme === 'dark' ? '🌞' : '🌙';
Update the button’s aria-label
for screen reader accessibility:
const label = theme === 'dark' ? 'Activate light mode' : 'Activate dark mode';
toggleThemeButton.setAttribute('aria-label', label);
Final function:
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
updateImagesByTheme();
const toggleThemeButton = document.getElementById('theme-toggle');
if (toggleThemeButton) {
toggleThemeButton.textContent = theme === 'dark' ? '🌞' : '🌙';
const label = theme === 'dark' ? 'Activate light mode' : 'Activate dark mode';
toggleThemeButton.setAttribute('aria-label', label);
}
}
This function is the core of the theming logic, all theme changes go through it.
3. initializeTheme()
: Determining the Theme on Page Load
This function runs on page load and decides which theme to apply using a simple logic:
- If a theme is saved in
localStorage
, use it. - Otherwise, check if the user’s system is set to dark mode (
prefers-color-scheme
). - By default, apply the light theme.
function initializeTheme() {
localStorage
is a persistent local storage mechanism provided by the browser.
Unlike cookies, it doesn't expire automatically and remains available even after the browser is closed.
We use it here to save the user’s preference and restore it on future visits.
Retrieve the previously saved theme from localStorage
(if the user has made a choice):
const saved = localStorage.getItem('theme');
Use the matchMedia
API to check if the user’s system is set to dark mode:
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
Determine which theme to apply:
- If a saved theme exists, use it.
- Otherwise, apply 'dark' if the system prefers dark mode, or 'light' by default.
const theme = saved || (prefersDark ? 'dark' : 'light');
Apply the selected theme and update related images:
applyTheme(theme);
Final function:
function initializeTheme() {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (prefersDark ? 'dark' : 'light');
applyTheme(theme);
}
This function ensures a consistent experience right from the initial load while respecting the user’s preferences.
4. Handling the #theme-toggle
Button
Finally, we add a click event listener to the theme toggle button:
document.getElementById('theme-toggle')?.addEventListener('click', () => {
Retrieve the currently applied theme using the data-theme
attribute on the <html>
element:
const current = document.documentElement.getAttribute('data-theme');
Determine the opposite theme, if the current one is "dark", switch to "light", and vice versa:
const newTheme = current === 'dark' ? 'light' : 'dark';
Save the new theme to localStorage
so it persists across future visits:
localStorage.setItem('theme', newTheme);
Apply the selected theme and update the associated images:
applyTheme(newTheme);
Final addEventListener
implementation:
document.getElementById('theme-toggle')?.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const newTheme = current === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', newTheme);
applyTheme(newTheme);
});
5. Initialization on Load
We finish by calling initializeTheme()
as soon as the script loads, to immediately apply the appropriate theme.
initializeTheme();
6. Final Code Summary
With just a few JavaScript functions:
- The site automatically adapts to the system theme or saved user preference,
- Users can manually switch themes at any time,
- Images update dynamically to match the interface theme.
This system is simple, robust, and easily extensible to other elements (icons, charts, backgrounds, etc.).
function updateImagesByTheme() {
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
document.querySelectorAll('img.has-dark').forEach((img) => {
const originalSrc = img.dataset.src || img.getAttribute("src");
if (!img.dataset.src) img.dataset.src = originalSrc;
img.src = isDark
? originalSrc.replace(/(\.\w+)$/, "-dark$1")
: img.dataset.src;
});
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
updateImagesByTheme();
const toggleThemeButton = document.getElementById('theme-toggle');
if (toggleThemeButton) {
toggleThemeButton.textContent = theme === 'dark' ? '🌞' : '🌙';
const label = theme === 'dark' ? 'Activate light mode' : 'Activate dark mode';
toggleThemeButton.setAttribute('aria-label', label);
}
}
function initializeTheme() {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (prefersDark ? 'dark' : 'light');
applyTheme(theme);
}
document.getElementById('theme-toggle')?.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const newTheme = current === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', newTheme);
applyTheme(newTheme);
});
initializeTheme();
IV. Conclusion
In this third part, we explored how JavaScript makes a light/dark theme system interactive, dynamic, and customizable.
With just a few well-structured functions:
- The theme can be automatically applied based on user or system preferences,
- An accessible toggle button allows users to easily switch modes,
- Images adapt dynamically to stay visually consistent with the active theme.
This system relies on a simple principle: control the data-theme
attribute and centralize the theme state within the DOM and localStorage
.
This model offers several key benefits:
- Persistent: the theme remains active between visits,
- Interactively controllable: through a user-friendly interface,
- Visually consistent: images align with the selected theme.
This solid foundation can later be extended with more advanced features, such as adaptive theming based on time of day, or user preferences stored on the server.
The Complete Guide
-
May 2025 HTML CSS JS 🌙 Dark Mode 🧩 UX ♿ Accessibility
0. Managing Light and Dark Themes with HTML, CSS, and JavaScript: Introduction
-
May 2025 🌙 Dark Mode 🧩 UX ♿ Accessibility
1. Managing Light/Dark Themes: Basics, Accessibility, and Best Practices
-
May 2025 HTML CSS 🌙 Dark Mode 🧩 UX ♿ Accessibility
2. Managing Light/Dark Themes with HTML and CSS: Native Solution
-
May 2025 HTML CSS JS 🌙 Dark Mode 🧩 UX ♿ Accessibility
3. Managing Light/Dark Themes with JavaScript: Giving Users Full Control
browserux.css is a base CSS file designed as a modern alternative to classic resets and Normalize.css, focused on user experience and accessibility.
It lays accessible, consistent foundations adapted to today's web usage: browserux.css
Go Further
If you’d like to explore how to better account for user preferences in your interfaces, here are a few resources you may find helpful:
-
May 2025 CSS ♿ Accessibility 🧩 UX
CSS: Improve Accessibility by Respecting Users’ Contrast Preferences with
prefers-contrast
-
May 2025 CSS ♿ Accessibility 🧩 UX
CSS: Adapting Animations to User Preferences with
prefers-reduced-motion
About
This blog was designed as a natural extension of the BrowserUX Starter and browserux.css projects.
Its goal is to provide complementary resources, focused tips, and detailed explanations around the technical choices, best practices, and accessibility principles that structure these tools.
Each article or tip sheds light on a specific aspect of modern front-end (CSS, accessibility, UX, performance…), with a clear intention: to explain the “why” behind each rule to encourage more thoughtful and sustainable integration in your projects.