Back to blog
Web Development

How to Add Dark Mode to Your Website Using CSS and JavaScript

Add a polished dark mode with CSS custom properties, prefers-color-scheme, and a small JavaScript toggle that saves the choice and never flashes white on load.

AdminOctober 19, 202610 min read2 views
How to Add Dark Mode to Your Website Using CSS and JavaScript

How to Add Dark Mode to Your Website Using CSS and JavaScript

A good dark mode is not an inverted color filter. It is a second, carefully designed theme that respects the user's system preference, remembers manual choices, and never flashes white on load. Dark mode is an alternate color scheme using light text on dark surfaces, and with CSS custom properties plus a small amount of JavaScript, you can add one to almost any website in an afternoon.

Quick Answer: Add dark mode by defining colors as CSS custom properties, overriding them inside a prefers-color-scheme: dark media query and a data-theme attribute, and adding a toggle button whose JavaScript sets data-theme on the html element and saves the choice in localStorage. Run a tiny inline script in the head to apply the saved theme before the page paints.

How Does Dark Mode Work in CSS?

Dark mode in CSS works by separating color decisions from components. Instead of writing color: #111 in dozens of rules, you define semantic variables such as --color-bg, --color-text, and --color-surface once, use them everywhere, and swap their values for the dark theme. Components never change; only the variable values do.

Two mechanisms decide which values apply. The prefers-color-scheme media query reports whether the operating system or browser is set to dark, so @media (prefers-color-scheme: dark) lets you follow the user's system automatically with zero JavaScript. A manual override, usually a data-theme="dark" or data-theme="light" attribute on the html element, lets users choose differently for your site specifically. Building that toggle is a great exercise if you are still working through beginner JavaScript projects, because it combines attributes, events, and saved state.

The color-scheme property is the third piece, and it is often forgotten. Setting color-scheme: light dark on the root tells the browser your page supports both, so native form controls, scrollbars, and default backgrounds adapt correctly. Without it, a dark page can display bright white input fields and scrollbars.

Modern CSS also offers the light-dark() function, which takes a light and a dark value and picks one based on the active color scheme, for example --color-bg: light-dark(#ffffff, #121212);. It is supported in current versions of the major browsers and simplifies theme files considerably, though checking support for your audience is still wise.

Step-by-Step Dark Mode Implementation

Follow these steps in order. The result supports system preference, manual choice, persistence, and no flash of the wrong theme.

  1. Audit your colors. Search your stylesheet for every hex, rgb, and named color. Group them by role: background, surface, text, muted text, border, accent, and states such as error and success.
  2. Create semantic custom properties. In :root, define one variable per role with light values, and set color-scheme: light. Replace every hard-coded color in your components with the matching variable.
  3. Add the system dark theme. Inside @media (prefers-color-scheme: dark), target :root:not([data-theme="light"]) and override each variable with its dark value, plus color-scheme: dark.
  4. Add the manual dark theme. Write the same dark overrides for :root[data-theme="dark"]. Explicit light is handled automatically because the default values are light and the media query excludes it.
  5. Add the toggle button. Place a <button type="button" aria-pressed="false"> with a clear label such as "Dark mode" in your header. A button with aria-pressed correctly communicates an on or off state.
  6. Write the toggle script. On click, determine the current effective theme, switch to the other, set document.documentElement.dataset.theme, update aria-pressed, and save the value with localStorage.setItem('theme', value).
  7. Prevent the flash. Put a tiny inline script at the top of the head that reads localStorage.getItem('theme') and, if present, sets the attribute immediately. Because it runs before the body renders, the correct theme appears on the first paint.
  8. Handle images and media. Use <picture> with a media="(prefers-color-scheme: dark)" source for logos that need a dark variant, and slightly reduce the brightness of large photos in dark mode if they feel glaring.
  9. Test every state. Check system light, system dark, manual override in each direction, page reload, and a fresh private window.

The toggle logic is compact: compute const isDark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;, then set the opposite value. That one line handles both the system default and the saved override.

Comparing Dark Mode Approaches

There are several ways to implement dark mode, and they differ in effort, flexibility, and user experience. This table compares the common options.

ApproachJavaScript RequiredUser Can OverrideBest For
prefers-color-scheme onlyNoneNo, follows the systemSimple blogs and documentation
Custom properties plus data-theme toggleSmall toggle and head scriptYes, persistedMost websites and apps
Separate dark stylesheet swapYes, swaps link elementsYesLegacy sites with hard-coded colors
CSS filter invertOptionalYesQuick prototypes only; distorts images and brand colors
light-dark() function with color-schemeNone, or small toggleYes, when combined with a toggleModern codebases targeting current browsers

For nearly every site, the custom properties approach is the right default. It keeps one stylesheet, honors system preferences, lets users override them, and scales cleanly as you add components. The invert filter looks tempting but inverts photos, brand colors, and shadows, producing results that feel broken.

Designing an Accessible Dark Theme

A dark theme must meet the same contrast requirements as a light one, and some issues only appear in dark mode. The W3C's WCAG guidelines require a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text at level AA. These apply to every theme you ship, not just the default.

Avoid pure black with pure white text. Contrast that extreme can cause a smearing or halo effect for some readers, particularly with thin fonts. Dark grays such as #121212 for backgrounds and off-white text such as #e8e8e8 remain comfortably above the minimum while reducing glare. This is a widely followed design practice, including in Google's Material Design guidance.

Express elevation with lighter surfaces. Shadows barely show on dark backgrounds. Raised elements such as cards and menus should use slightly lighter surface colors instead, so hierarchy remains visible.

Desaturate accents. A saturated brand blue that works on white often vibrates against dark gray. Use a lighter, slightly less saturated variant for dark mode and re-check its contrast.

Re-check every state. Focus rings, disabled buttons, placeholder text, error messages, and links are where dark themes most often fail contrast. Our guide to contrast requirements for accessible design explains how to test them with browser tools and how to fix failing combinations.

Common Dark Mode Mistakes and How to Fix Them

These are the problems I find most frequently when reviewing dark mode implementations. Most take minutes to fix once spotted.

  • Flash of light theme on load. Applying the saved theme in a deferred script causes a white flash. Move theme detection into a tiny inline script in the head.
  • Hard-coded colors left behind. One forgotten color: #333 produces unreadable dark text on a dark background. Search the codebase for raw color values after migrating.
  • Ignoring the system preference. Defaulting everyone to light and requiring a click to go dark ignores a choice users already made. Follow prefers-color-scheme until they override it.
  • Missing color-scheme declaration. Without it, native inputs, selects, and scrollbars stay bright. Set color-scheme for each theme.
  • Icon-only toggle without a label. A sun or moon icon alone is ambiguous and silent for screen readers. Provide an accessible name and state with aria-pressed or a clear text label.
  • Transparent logos that disappear. Dark logos vanish on dark backgrounds. Supply a light variant through a picture element or an SVG using currentColor.
  • Animating every color change. A global transition on all colors makes theme switching feel sluggish and can cause flicker on load. Keep transitions targeted, and skip them entirely for users who prefer reduced motion.

Real-World Scenario: Retrofitting Dark Mode on an Existing Blog

Here is how I approached adding dark mode to a content-heavy blog with an existing stylesheet of roughly a thousand lines and colors scattered throughout.

Inventory. A quick search found many raw color values, but after grouping they collapsed into about a dozen roles. Nearly identical grays were consolidated into a single --color-text-muted, which cleaned up the light theme as a side benefit.

Migration. Every raw color was replaced with a variable while keeping the light theme pixel-identical. This was verified by comparing screenshots of key templates before and after, so the light experience did not change at all.

Dark palette. Background #121212, raised surface #1e1e1e, body text #e6e6e6, muted text #a8a8a8, borders #2e2e2e, and a lightened brand accent. Each text-and-background pair was checked against the 4.5:1 minimum before shipping.

Content details. Code blocks received a dark syntax palette. Embedded charts with white backgrounds were given a light rounded surface so they read as intentional cards rather than glaring rectangles. Author photos were left untouched.

Toggle and persistence. A labeled button in the header, an inline head script for flash prevention, and localStorage persistence. The default followed the system preference.

Verification. Every template was tested in both themes at mobile and desktop widths, the toggle was tested with keyboard and a screen reader, and reloads were tested in each state. Because the components themselves never changed, regressions were limited to a handful of missed hard-coded colors, which were found and fixed in the first review pass.

Key Takeaways

  • CSS custom properties let you swap an entire color theme by changing variable values, without touching component styles.
  • The prefers-color-scheme media query honors the user's system setting automatically and should be the default behavior.
  • A small inline script in the head that applies the saved theme before first paint eliminates the flash of the wrong theme.
  • Dark themes must meet WCAG contrast minimums of 4.5:1 for normal text and 3:1 for large text, just like light themes.
  • Setting the color-scheme property makes native form controls and scrollbars match the active theme.

Frequently Asked Questions

Can I add dark mode with only CSS?

Yes. Using the prefers-color-scheme media query with CSS custom properties, your site automatically follows the user's system theme with no JavaScript. The limitation is that users cannot choose a different theme for your site alone. Adding a manual toggle with saved preference requires a small amount of JavaScript.

How do I stop the white flash when loading dark mode?

Place a small inline script at the top of the head element that reads the saved theme from localStorage and sets the data-theme attribute on the html element immediately. Because it runs before the body renders, the browser paints the correct theme first. Deferred or external scripts run too late to prevent the flash.

Should dark mode use pure black backgrounds?

Usually not. Pure black with pure white text creates extreme contrast that some readers find uncomfortable, especially with thin fonts. Dark grays around #121212 with off-white text stay well above WCAG contrast minimums while reducing glare. Pure black is sometimes chosen for OLED-focused apps, but it is a deliberate trade-off.

Where should I save the user's theme choice?

For a website without accounts, localStorage is appropriate because the theme is a harmless device-level display preference. For logged-in apps, also save it to the user's profile on the server so it follows them across devices. Never block rendering on a network request just to determine the theme.

Does dark mode affect SEO?

Dark mode does not directly affect rankings. Search engines index your content and HTML regardless of color scheme. Indirectly, a comfortable reading experience can support engagement, while a broken implementation with unreadable text could hurt usability. Ensure content is identical in both themes and that contrast passes in each.

How do I handle images in dark mode?

Use a picture element with a source targeting prefers-color-scheme: dark for logos and diagrams that need a dark variant. Use SVG icons with currentColor so they inherit text color. For photos, leave them unchanged or apply a slight brightness reduction, but never invert them, since that distorts their content.

Conclusion

The decision that makes or breaks dark mode is architectural: move every color into semantic custom properties first, and the theme switch becomes a matter of swapping values. Honor the system preference, persist manual choices, and prevent the flash with an inline head script. Your next step is to audit your colors today, and once theming is solid, refine the experience further with scroll animations that respect user preferences using the same media-query mindset.

Chat on WhatsApp