Front-End · 8 min read · Updated

Dark Mode Design and CSS: From Colour Choices to a Working Toggle

Design a comfortable dark theme and build it with CSS variables, prefers-color-scheme and a small toggle that remembers the user's choice.

Dark mode: macbook Laptop
Photo: Radek Grzybowski via Stocksnap (CC0)

Dark mode is no longer a novelty. Every major operating system offers it, and many users expect websites and apps to follow their system setting. A good dark theme is not simply “invert the colours”, though. It needs its own design decisions, and the CSS needs to be organised so that the two themes do not become two separate stylesheets to maintain.

This guide covers both halves: how to design a dark theme that is comfortable to read, and how to build it cleanly with CSS.

Part 1: Designing a dark theme

Avoid pure black and pure white

Pure white text (#ffffff) on pure black (#000000) has maximum contrast, which sounds good but can cause a glowing, smeared effect for many readers, especially in long text. Most well-known dark themes use a very dark grey or a dark tinted background, and slightly softened light text. For example, a background around #121212 to #1a1d24 and text around #e6e6e6.

Keep contrast within accessible ranges

Your dark theme must still meet WCAG contrast requirements: at least 4.5:1 for normal text and 3:1 for large text and important UI elements such as input borders and icons. Check every text colour against the background it sits on – secondary grey text is the usual failure. More detail in Web Accessibility Basics and Color Theory for UI Designers.

Show elevation with lighter surfaces, not shadows

In a light theme, cards and menus float above the page using shadows. On a dark background, shadows are almost invisible. Instead, raised surfaces get slightly lighter: the page is darkest, cards are a little lighter, menus and dialogs lighter again.

LayerLight themeDark theme
Page background#f7f7f5#121417
Card / surface#ffffff + soft shadow#1c1f24
Raised (menu, dialog)#ffffff + stronger shadow#262a31
Primary text#1d1d1f#e8e8ea
Secondary text#5b5f66#a9adb5
Border#d9dbe0#363b44

Desaturate bright brand colours

Strong saturated colours that look fine on white can vibrate on a dark background and fail contrast. Use a lighter, slightly less saturated version of your brand colour for buttons and links in dark mode. A deep blue #1f4fd1 on white might become #8fb0ff on dark grey.

Think about images and illustrations

  • Photos usually work in both themes; you might reduce their brightness slightly.
  • Logos and icons with dark strokes need a light version.
  • Screenshots with white backgrounds can be glaring – add a border or rounded frame.
  • Charts need a second palette that keeps contrast on dark surfaces.

Tip: In Figma, set up colour Variables with two modes, “Light” and “Dark”. Every component then switches theme by changing the mode on its frame. Name variables by purpose (surface/raised, text/secondary), not by colour, and match those names in your CSS. See Design Systems for Beginners.

Part 2: Building dark mode with CSS

Step 1: define colour tokens as custom properties

Put every colour in a CSS custom property on :root. Components only ever use the variables, never raw hex codes. Switching theme then means redefining the variables in one place.

Step 2: follow the system setting

The prefers-color-scheme media feature matches the user’s operating system or browser preference. Also set the color-scheme property so the browser draws native form controls and scrollbars in the matching style.

Step 3: let users override it

Some people use a dark system theme but prefer light pages for reading, or the other way round. A toggle that sets a data-theme attribute on <html> handles this. The demo below combines all three steps.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dark mode demo</title>
<style>
  :root {
    color-scheme: light;
    --bg: #f7f7f5; --surface: #ffffff; --text: #1d1d1f;
    --muted: #5b5f66; --border: #d9dbe0; --accent: #1f4fd1; --on-accent: #ffffff;
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      color-scheme: dark;
      --bg: #121417; --surface: #1c1f24; --text: #e8e8ea;
      --muted: #a9adb5; --border: #363b44; --accent: #8fb0ff; --on-accent: #10131a;
    }
  }
  :root[data-theme="dark"] {
    color-scheme: dark;
    --bg: #121417; --surface: #1c1f24; --text: #e8e8ea;
    --muted: #a9adb5; --border: #363b44; --accent: #8fb0ff; --on-accent: #10131a;
  }
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif;
         background: var(--bg); color: var(--text); }
  .card { background: var(--surface); border: 1px solid var(--border);
          border-radius: 10px; padding: 16px; max-width: 420px; }
  .muted { color: var(--muted); }
  .btn { font: inherit; padding: 8px 14px; border-radius: 6px; border: 0;
         background: var(--accent); color: var(--on-accent); cursor: pointer; }
  label { display: block; margin: 12px 0 4px; }
  input { font: inherit; padding: 6px; width: 100%; box-sizing: border-box; }
</style>
</head>
<body>
  <p>
    Theme:
    <button class="btn" data-set="light">Light</button>
    <button class="btn" data-set="dark">Dark</button>
    <button class="btn" data-set="system">System</button>
  </p>
  <div class="card">
    <h1 style="margin-top:0">Booking summary</h1>
    <p class="muted">Two nights in Thanjavur, 2 guests</p>
    <label for="name">Your name</label>
    <input id="name" value="Kavya R">
    <p><button class="btn">Confirm booking</button></p>
  </div>
  <p class="muted" id="current"></p>
  <script>
    var root = document.documentElement;
    function apply(choice) {
      if (choice === 'system') { root.removeAttribute('data-theme'); }
      else { root.setAttribute('data-theme', choice); }
      document.getElementById('current').textContent = 'Current choice: ' + choice;
      try { localStorage.setItem('theme', choice); } catch (e) {}
    }
    var saved = 'system';
    try { saved = localStorage.getItem('theme') || 'system'; } catch (e) {}
    apply(saved);
    document.querySelectorAll('[data-set]').forEach(function (btn) {
      btn.addEventListener('click', function () { apply(btn.dataset.set); });
    });
  </script>
</body>
</html>

How it works: the light values are the default. If the system prefers dark and the user has not forced light, the dark values apply. If the user forces dark, the attribute rule applies regardless of the system. “System” removes the attribute so the media query is in charge again.

Avoid the flash of the wrong theme

If you store the user’s choice and only apply it after the page has loaded, the page may flash light before turning dark. To avoid that, put a tiny inline script in the <head>, before your stylesheet renders, that reads the saved choice and sets data-theme immediately. Keep it to a few lines so it does not delay the page.

<head>
  <meta charset="utf-8">
  <script>
    try {
      var t = localStorage.getItem('theme');
      if (t === 'light' || t === 'dark') {
        document.documentElement.setAttribute('data-theme', t);
      }
    } catch (e) {}
  </script>
  <link rel="stylesheet" href="styles.css">
</head>

The try...catch matters: in some private browsing modes or with storage blocked, reading localStorage throws an error. Without the guard, one blocked setting could break the rest of your scripts. If storage is unavailable, the page simply falls back to the system preference, which is a sensible default.

Should the theme change animate?

A short fade of background and text colours when the user clicks the toggle can feel pleasant. Keep it under about 200 ms, apply it only after the first paint (otherwise the page fades in on every load), and switch it off for users who prefer reduced motion.

A shorter option: light-dark()

Modern browsers support the light-dark() colour function. With color-scheme: light dark on the root, you can write color: light-dark(#1d1d1f, #e8e8ea) and the browser chooses the value that matches the active scheme. It is handy for small sites; for larger design systems, separate token blocks like the demo are often easier to read and review.

Testing your dark theme

  1. In Chrome DevTools, open the Rendering panel and use “Emulate CSS media feature prefers-color-scheme” to switch quickly.
  2. Check contrast of every text style and every interactive border in both themes.
  3. Check focus outlines – a dark blue focus ring can disappear on a dark background.
  4. Check forms, date pickers and scrollbars; color-scheme should handle most native controls.
  5. Look at images, logos, embedded maps and videos in both themes.
  6. Test in a dim room on a real phone – that is where most people use dark mode.

Common mistakes

  • Hard-coded hex values inside components, which do not change with the theme.
  • Using the same saturated brand colour in both themes.
  • Relying on shadows for elevation in dark mode.
  • Only following the system setting with no way to override it.
  • Forgetting third-party widgets that stay bright white.
  • Making the toggle an unlabelled icon. Give it an accessible name such as “Switch to dark theme” and show the current state.

Next steps: get comfortable with CSS Colors and Backgrounds, then explore smooth theme transitions safely with CSS Animation Performance Tips.

Frequently asked questions

Is dark mode better for the eyes?

It depends on the person and the environment. Some find it more comfortable in low light, while others read long text more easily in a light theme. That is why it is best to offer both and respect the user’s choice.

What is the best background colour for dark mode?

A very dark grey or dark tinted colour rather than pure black is a common choice, because it reduces glare and lets you show elevation with slightly lighter surfaces.

What does the color-scheme property do?

It tells the browser which colour schemes the page supports, so native elements such as form controls and scrollbars are drawn in matching light or dark styles.

Can I use CSS variables inside a media query for dark mode?

You can redefine variables inside a prefers-color-scheme media query, which is the usual approach. You cannot use a variable as the media query condition itself.

Should dark mode be the default?

Usually the best default is to follow the user’s system setting, with a visible toggle to override it.

Further reading

Keep reading

All articles