The Ultimate Guide: How to Add Dark Mode to Your Website (2024 Edition)
Dark mode has evolved from a niche preference into a mainstream expectation for modern web users. Studies show that over 80% of operating systems now offer system-wide dark themes, and users have come to expect the same level of flexibility from the websites they visit daily. Implementing a robust dark mode not only reduces eye strain in low-light environments but can also improve battery life on OLED and AMOLED screens, enhance visual hierarchy by reducing glare, and ultimately boost user engagement and satisfaction. Whether you are building a news portal, a SaaS dashboard, or a personal blog, adding dark mode demonstrates a commitment to user-centric design and accessibility.
However, adding dark mode is not as simple as inverting colors. A poorly executed dark mode can wreak havoc on readability, brand consistency, and image visibility. This guide will walk you through a production-ready approach that uses CSS custom properties, the `prefers-color-scheme` media query, JavaScript for manual toggles, and localStorage for persistence. By the end, you will have a fully functional dark mode that respects the user’s system preference, offers a manual override, and stores that choice across sessions. We also cover best practices for images, icons, SVG handling, performance, and accessibility—ensuring your dark mode is both beautiful and inclusive.
Step 1: Lay the Foundation with CSS Custom Properties
Before writing a single line of JavaScript, you must first define your color palette in a way that can be easily swapped. CSS custom properties (also known as CSS variables) are the gold standard for theming because they allow you to change many values at once by overriding the variable in a different scope or class. Start by declaring your light‑theme colors on the `:root` pseudo‑class. Use semantic names like `–color-background`, `–color-text`, `–color-primary`, `–color-secondary`, `–color-border`, and `–color-surface`. This naming convention is crucial because it abstracts colors away from literal hex values—if you later decide to tweak a shade, you only change it in one place.
For the dark theme, you will create a separate set of custom properties, typically inside a `.dark-mode` class or using a `[data-theme=”dark”]` attribute on the `` element. Using an attribute is often cleaner because it allows you to apply the theme globally without additional class names on every container. Here is an example of the structure:
:root {
--color-background: #ffffff;
--color-text: #1a1a1a;
--color-primary: #0066cc;
--color-surface: #f5f5f5;
--color-border: #e0e0e0;
}
[data-theme="dark"] {
--color-background: #121212;
--color-text: #e0e0e0;
--color-primary: #82b1ff;
--color-surface: #1e1e1e;
--color-border: #333333;
}
Now, instead of writing `color: #1a1a1a;` throughout your stylesheets, you write `color: var(–color-text);`. The browser will automatically use the appropriate variable based on whether the `[data-theme=”dark”]` attribute is present. This approach scales beautifully—you can add dozens of variables for shadows, gradients, hover states, and even border radii if needed. Avoid hardcoding any color values inside components; always reference a custom property. This makes future theme expansion (e.g., a high‑contrast mode or a brand‑specific alternate theme) trivial.
Step 2: Build the Light and Dark Theme Stylesheets
With your CSS variables defined, you now need to apply them to your entire website. The most efficient way is to set the background and text color on the `
` element using the variables, and then let inheritance do the rest. However, you must also ensure that every UI component—buttons, cards, forms, modals, navigation—uses the same variable pattern. Create a base file (e.g., `theme.css`) that contains all variable‑based styles. For example:body {
background-color: var(--color-background);
color: var(--color-text);
transition: background-color 0.3s ease, color 0.3s ease;
}
.card {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
}
a {
color: var(--color-primary);
}
Notice the `transition` property on the `body`. Adding a brief transition (0.3s) for `background-color` and `color` creates a smooth fade effect when the user toggles dark mode. Without it, the change is instantaneous and can feel jarring. You can extend this transition to all elements by applying it to `*` if performance allows, but be cautious because animating too many properties on many elements can cause jank. A good middle ground is to add transitions only to elements that change their background or text color.
For complex components like data tables, charts (e.g., Chart.js or D3), and code highlight blocks, you may need to override vendor styles. If you are using a CSS framework like Bootstrap 5, Tailwind CSS, or Materialize, you can use their built‑in dark mode utilities or custom variants. For example, Tailwind offers a `dark:` prefix that works hand‑in‑hand with the `class` strategy (adding a `dark` class to ``). In Bootstrap 5.3+, dark mode is natively supported by adding `data-bs-theme=”dark”` to the `` element. Regardless of the framework, the core principle remains: leverage CSS variables to centralize color management.
Step 3: Integrate the `prefers-color-scheme` Media Query for Automatic Detection
Modern browsers support the `prefers-color-scheme` media query, which reads the user’s operating system theme setting. If the user has selected “Dark” in their OS settings, the media query will match and you can automatically apply your dark theme without any user action. This is the foundational step for a seamless experience. You have two ways to implement it: using a pure CSS media query, or combining it with JavaScript.
The pure CSS approach is straightforward but limited—it removes the user’s ability to manually override the theme. In your main stylesheet, after defining your default light variables, you add:
@media (prefers-color-scheme: dark) {
:root {
--color-background: #121212;
--color-text: #e0e0e0;
/* etc. */
}
}
This works, but once set, the user cannot switch back to light mode if they prefer it for your specific site. A better approach is to use JavaScript to detect the media query and then apply the appropriate attribute to the `` element, while also allowing manual toggling. You can do this with `window.matchMedia(‘(prefers-color-scheme: dark)’)`. When the page loads, check this value and set the `data-theme` attribute accordingly. Additionally, listen for changes to the system theme (e.g., when the user switches from light to dark in their OS settings) using the `change` event on the `MediaQueryList` object. This way, your website stays in sync with the OS even while the user is browsing.
Here is a compact JavaScript snippet that accomplishes this:
const themeToggle = document.getElementById('theme-toggle');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
function setTheme(isDark) {
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
}
// Set initial theme based on OS preference
setTheme(prefersDark.matches);
// Listen for OS theme changes
prefersDark.addEventListener('change', (e) => setTheme(e.matches));
// Manual toggle (see Step 4)
themeToggle.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme === 'dark');
localStorage.setItem('theme', newTheme); // Explained in Step 5
});
This code ensures that the website respects the system preference by default, yet paves the way for a manual override.
Step 4: Add a User-Controlled Toggle with JavaScript
Empowering the user to manually switch between light and dark modes is essential because there are many reasons someone might prefer the opposite of their OS setting—for example, reading in a bright room with a phone set to dark mode. The toggle should be easily accessible, typically placed in the website’s header, navigation, or a floating button. The most common implementation is a button with an icon that changes between a sun (for light mode) and a moon (for dark mode).
Start by adding a simple HTML button:
<button id="theme-toggle" aria-label="Toggle dark mode">
<svg class="sun-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<svg class="moon-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
Use CSS to show only the appropriate icon based on the current theme. For instance, when `data-theme=”light”`, display `.sun-icon` and hide `.moon-icon`. Alternatively, you can swap the icons via JavaScript by toggling a class. The key is to provide both visual and textual feedback (using `aria-label` or a tooltip) so that the toggle’s purpose is clear to all users, including those using screen readers.
Now integrate the toggle with the `setTheme` function from Step 3. When the button is clicked, read the current `data-theme` value, flip it, and call `setTheme`. Then, save the user’s choice to `localStorage` as shown in the next step. Ensure you also update the media query listener to not override the manual preference. A common pattern is to set a flag `manualOverride = true` when the user clicks the toggle, and then in the `prefers-color-scheme` change handler, only update the theme if `manualOverride` is false.
Step 5: Persist the User’s Choice with localStorage
If a user manually toggles dark mode but the preference is not saved, the site will revert to the system default on the next page load. To avoid this frustration, store the user’s chosen theme in the browser’s `localStorage`. When the page loads, your JavaScript should first check `localStorage` for a saved theme. If it exists, use that value; otherwise, fall back to the OS media query. This is known as the “priority order”: manual override > system preference > default light.
Update your page initialization code:
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
setTheme(savedTheme === 'dark');
} else {
setTheme(prefersDark.matches);
}
Notice that you no longer call `setTheme(prefersDark.matches)` unconditionally; you only use the media query as the fallback when no manual preference exists. Also, when the user toggles the theme, you save the new value:
themeToggle.addEventListener('click', () => {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const newTheme = isDark ? 'light' : 'dark';
setTheme(newTheme === 'dark');
localStorage.setItem('theme', newTheme);
// Optionally set manualOverride = true to prevent OS changes from overriding
});
What about the case where the user clears their browser cache or uses private browsing? `localStorage` is not available in some strict privacy modes (e.g., Chrome Incognito blocks `localStorage` in third-party contexts). Always wrap `localStorage` calls in a try/catch to avoid errors. If `localStorage` is not available, simply fall back to the system preference or the default theme. Another best practice is to synchronize the `localStorage` change when the OS preference changes, but only if no manual override exists. You can accomplish this by using a flag `userOverride` that is set to `true` when the toggle is clicked, and remains `true` for the session unless the user clears their localStorage or explicitly resets to system default.
Below is a complete initialization function incorporating all the above logic:
function initTheme() {
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
let userOverride = false;
function applyTheme(isDark) {
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
}
function setTheme(isDark, fromToggle = false) {
applyTheme(isDark);
if (fromToggle) {
userOverride = true;
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
}
// Initial load
if (savedTheme) {
applyTheme(savedTheme === 'dark');
userOverride = true; // If savedTheme exists, treat it as an override
} else {
applyTheme(prefersDark.matches);
}
// Listen for OS changes only if no manual override
prefersDark.addEventListener('change', (e) => {
if (!userOverride) {
applyTheme(e.matches);
}
});
// Toggle button listener
const toggle = document.getElementById('theme-toggle');
if (toggle) {
toggle.addEventListener('click', () => {
const currentIsDark = document.documentElement.getAttribute('data-theme') === 'dark';
setTheme(!currentIsDark, true);
});
}
}
document.addEventListener('DOMContentLoaded', initTheme);
Step 6: Optimize Images, SVGs, and Media for Dark Mode
A common pitfall in dark mode implementations is images that look blown out or icons that disappear against a dark background. Not all assets need to change, but consider the following categories:
- Photographs and raster images: Simply inverting colors rarely works. Instead, you can reduce the image brightness or apply a CSS filter like `filter: brightness(0.8) saturate(1.2);` to make them blend better. For logos and hero images, you might want to replace them entirely with a dark‑mode variant using `
` elements and the `media` attribute. - SVG icons: If your icons use a `fill` or `stroke` attribute with a hardcoded hex color, they will not adapt to the theme. The best solution is to remove the fill/stroke from the SVG file and use CSS to color them with `currentColor`. Then set `color: var(–color-text)` on the parent container. Alternatively, use inline SVGs and set the color via CSS custom properties.
- Iframes (YouTube, Vimeo, etc.): Many embedded video players have their own dark mode settings. You can try adding `&theme=dark` to the URL parameters, but it may not always work. A simpler approach is to wrap the iframe in a container and apply a CSS overlay or reduce the container’s opacity.
For a more advanced technique, you can use the CSS `mix-blend-mode` property to subtly adjust how images blend with the dark background. For example, applying `mix-blend-mode: luminosity` to an image can preserve its shapes while normalizing colors to the underlying theme. However, this should be tested thoroughly because it can produce unexpected effects.
Step 7: Accessibility and Responsive Considerations
Dark mode is not just a visual preference; for some users with visual impairments (e.g., photophobia or certain types of color blindness), it is an accessibility necessity. Ensure that your dark mode maintains sufficient color contrast. The Web Content Accessibility Guidelines (WCAG) recommend a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text. When designing your dark theme, check contrast using tools like the WebAIM contrast checker. Avoid using pure black (#000) on pure white (#fff) because it can cause halation—instead, use a dark gray like #121212 for the background and a light gray like #e0e0e0 for text.
Also consider that some users may have set their device to “Reduce Motion” in accessibility settings. Your CSS transitions (e.g., the 0.3s background fade) should be wrapped in a `prefers-reduced-motion` media query to disable them when appropriate:
@media (prefers-reduced-motion: no-preference) {
body {
transition: background-color 0.3s ease, color 0.3s ease;
}
}
Finally, test your dark mode on multiple devices and browsers. Some older browsers (e.g., Internet Explorer 11) do not support CSS custom properties or `prefers-color-scheme`. For those cases, provide a fallback: your light theme will load by default, and you can use a polyfill for CSS variables if needed. Mobile devices also require careful testing because battery life improvements are most noticeable on OLED screens. You can use Chrome DevTools’ rendering panel to emulate `prefers-color-scheme` and check all UI states.
Tips and Best Practices
Tip 1: Use CSS Custom Properties with Fallbacks
When you define your components, you can provide a fallback value in case the custom property is not defined. For example: `background-color: var(–color-surface, #f5f5f5);`. This ensures that even if your theming code fails to load (e.g., due to a network error), the page remains usable with a sensible default. Additionally, consider using `@supports (–custom: property)` to conditionally load dark mode enhancements only in browsers that support CSS variables.
Tip 2: Smooth Transitions Are a Must
As mentioned earlier, apply `transition` to color-changing properties. However, avoid applying transitions to everything—especially `box-shadow` and `filter`—as they are performance-heavy. Use `will-change` on the `body` or a wrapper element to hint the browser to prepare for animation. A common pattern is to add `transition: background-color 0.3s, color 0.3s, border-color 0.3s;` to the `*` selector, but only on the `:root` or `body` to limit performance impact.
Tip 3: Consider Server-Side Rendering (SSR) for SEO and Performance
If you are using a framework like Next.js, Nuxt.js, or Gatsby, you can detect the user’s preference on the server side using the `Sec-CH-Prefers-Color-Scheme` client hint header. This header is sent by Chromium browsers and allows the server to send the appropriate theme’s CSS immediately, avoiding a flash of the wrong theme on first paint. This is known as the “flash of incorrect theme” (FOIT) problem. Alternatively, you can inject a small inline script in the `
` that reads `localStorage` or `matchMedia` and applies the `data-theme` attribute before the rest of the page renders. This script should be synchronous and placed as the first child of ``.FAQ: Frequently Asked Questions
Q1: Can I add dark mode without any JavaScript?
Yes, you can use only the `@media (prefers-color-scheme: dark)` CSS media query as shown in Step 3. However, this approach does not allow the user to manually override the theme. If your audience is technical and respects their OS setting, this may suffice. For a broader user base, JavaScript is necessary for a manual toggle and persistence.
Q2: How do I prevent the white flash when loading a dark-mode site?
The flash occurs because the default theme (light) renders before your dark-mode script applies the `data-theme` attribute. To prevent it, add a small inline script in the `
` (before `` and `