Back to Blog

Dark Mode Done Right: Design Tokens and Implementation

Dark Mode Done Right: Design Tokens and Implementation

Dark mode is no longer optional. Users expect it. Operating systems default to it. And done well, it improves readability in low-light conditions, reduces eye strain, and can even save battery on OLED screens.

Done poorly, it is a maintenance nightmare. You end up with !important scattered everywhere, colors that look washed out or unreadable, and a theme toggle that flashes the wrong theme on every page load. We have shipped dark mode on multiple projects, including the Threshline website itself, and we have learned the hard way what works and what does not.

Why design tokens matter

The first mistake people make with dark mode is treating it as a color swap. Take every light color, find its dark equivalent, done. This leads to hundreds of one-off color values scattered across components, and changing any of them requires a find-and-replace across the entire codebase.

Design tokens solve this by creating a layer of abstraction between your design intent and the actual color values. Instead of color: #1a1a2e, you use color: var(--color-text-primary). The token --color-text-primary resolves to different values depending on the active theme.

This is the token structure we use:

/* Base palette — raw color values, never used directly in components */
:root {
  --palette-gray-50: #fafafa;
  --palette-gray-100: #f4f4f5;
  --palette-gray-200: #e4e4e7;
  --palette-gray-300: #d4d4d8;
  --palette-gray-400: #a1a1aa;
  --palette-gray-500: #71717a;
  --palette-gray-600: #52525b;
  --palette-gray-700: #3f3f46;
  --palette-gray-800: #27272a;
  --palette-gray-900: #18181b;
  --palette-gray-950: #09090b;

  --palette-blue-500: #3b82f6;
  --palette-blue-400: #60a5fa;

  --palette-red-500: #ef4444;
  --palette-red-400: #f87171;
}
/* Semantic tokens — these are what components actually use */
:root[data-theme="light"] {
  --color-bg-primary: var(--palette-gray-50);
  --color-bg-secondary: white;
  --color-bg-tertiary: var(--palette-gray-100);

  --color-text-primary: var(--palette-gray-900);
  --color-text-secondary: var(--palette-gray-600);
  --color-text-muted: var(--palette-gray-400);

  --color-border: var(--palette-gray-200);
  --color-border-strong: var(--palette-gray-300);

  --color-accent: var(--palette-blue-500);
  --color-error: var(--palette-red-500);

  --color-surface-elevated: white;
  --shadow-elevated: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
}

:root[data-theme="dark"] {
  --color-bg-primary: var(--palette-gray-950);
  --color-bg-secondary: var(--palette-gray-900);
  --color-bg-tertiary: var(--palette-gray-800);

  --color-text-primary: var(--palette-gray-100);
  --color-text-secondary: var(--palette-gray-400);
  --color-text-muted: var(--palette-gray-600);

  --color-border: var(--palette-gray-800);
  --color-border-strong: var(--palette-gray-700);

  --color-accent: var(--palette-blue-400);
  --color-error: var(--palette-red-400);

  --color-surface-elevated: var(--palette-gray-800);
  --shadow-elevated: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
}

Notice the naming convention: --color-[category]-[variant]. Components never reference --palette-gray-800 directly. They use --color-bg-tertiary. This means you can completely redesign your dark theme — swap the entire palette — without touching a single component file.

The three-layer architecture

We structure our tokens in three layers:

Layer 1: Palette (raw values). These are the actual hex codes. They define what colors exist. Components never use these directly.

Layer 2: Semantic tokens (intent). These define what colors mean. --color-text-primary is the main text color. --color-bg-secondary is a secondary background. The names describe purpose, not appearance.

Layer 3: Component tokens (optional, for complex components). For large design systems, you might have --button-bg, --button-text, --button-border that reference semantic tokens. For most projects, two layers are sufficient.

The semantic layer is where the magic happens. When you write a component, you think in terms of intent:

.card {
  background: var(--color-surface-elevated);
  border: 1px solid var(--color-border);
  color: var(--color-text-primary);
  box-shadow: var(--shadow-elevated);
  border-radius: 8px;
  padding: 1.5rem;
}

.card-title {
  color: var(--color-text-primary);
  font-weight: 600;
}

.card-description {
  color: var(--color-text-secondary);
  line-height: 1.6;
}

.card-meta {
  color: var(--color-text-muted);
  font-size: 0.875rem;
}

This card works in both themes without any theme-specific overrides. The token values change; the component CSS does not.

Design system color swatches organized into a structured palette

Handling the system preference

Most users want dark mode to match their operating system setting. CSS provides prefers-color-scheme for this, but you also need a manual toggle. The interaction between these two — system preference and user preference — is where most implementations get messy.

Here is the logic we use:

type Theme = 'light' | 'dark';

function getInitialTheme(): Theme {
  // 1. Check for saved user preference
  const saved = localStorage.getItem('theme') as Theme | null;
  if (saved === 'light' || saved === 'dark') {
    return saved;
  }

  // 2. Fall back to system preference
  if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
    return 'dark';
  }

  // 3. Default to light
  return 'light';
}

function applyTheme(theme: Theme): void {
  document.documentElement.setAttribute('data-theme', theme);
}

function toggleTheme(): void {
  const current = document.documentElement.getAttribute('data-theme') as Theme;
  const next: Theme = current === 'dark' ? 'light' : 'dark';
  localStorage.setItem('theme', next);
  applyTheme(next);
}

// Listen for system preference changes
// (only applies if user has not set a manual preference)
window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    if (!localStorage.getItem('theme')) {
      applyTheme(e.matches ? 'dark' : 'light');
    }
  });

The priority order matters: user preference overrides system preference, which overrides the default. And when the user explicitly toggles, we save that choice to localStorage so it persists across sessions.

Preventing the flash of wrong theme

This is the most common dark mode bug, and it is jarring: the page loads in light mode, then snaps to dark mode once JavaScript runs. Users see a white flash every time they navigate. It looks broken.

The fix is to apply the theme before the page renders, which means the logic needs to run in a blocking script in the <head>:

<head>
  <script>
    // This runs synchronously before the page renders
    (function() {
      var theme = localStorage.getItem('theme');
      if (!theme) {
        theme = window.matchMedia('(prefers-color-scheme: dark)').matches
          ? 'dark'
          : 'light';
      }
      document.documentElement.setAttribute('data-theme', theme);
    })();
  </script>
  <!-- rest of head -->
</head>

Yes, this is a blocking script. Yes, that is normally bad for performance. In this case, it is necessary and the script is tiny — a few microseconds of blocking to prevent a full-page visual flash. The tradeoff is worth it.

If you are using Astro (which we use for most of our projects, including this site), you can put this in your base layout’s <head>:

---
// BaseLayout.astro
---
<html>
  <head>
    <script is:inline>
      (function() {
        var theme = localStorage.getItem('theme');
        if (!theme) {
          theme = window.matchMedia('(prefers-color-scheme: dark)').matches
            ? 'dark'
            : 'light';
        }
        document.documentElement.setAttribute('data-theme', theme);
      })();
    </script>
  </head>
  <body>
    <slot />
  </body>
</html>

The is:inline directive in Astro prevents the script from being bundled and deferred — it stays exactly where you put it and runs synchronously.

Website displayed in a dark theme on a laptop screen

Dark mode is not just inverted colors

The biggest design mistake is treating dark mode as a simple inversion. Dark backgrounds with light text introduce different visual challenges:

Reduce contrast slightly. Pure white (#ffffff) on pure black (#000000) is harsh. It causes eye strain in dark environments. We use --palette-gray-100 (off-white) on --palette-gray-950 (near-black). The difference is subtle but significant for readability.

Increase accent brightness. Colors that look vibrant on a light background can look dull on a dark one. Notice in our token definitions that the dark theme uses --palette-blue-400 instead of --palette-blue-500. Lighter variants of accent colors maintain visual impact against dark backgrounds.

Adjust shadows. Shadows on a light background create depth by darkening the area below an element. On a dark background, those shadows are invisible — the background is already dark. You need stronger shadows (higher opacity, more spread) in dark mode to achieve the same perceived depth. Some designs replace shadows with subtle borders or use lighter surface colors for elevation.

/* Light mode: shadow creates depth */
.card {
  background: white;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

/* Dark mode: elevated surface color creates depth */
.card {
  background: var(--color-surface-elevated); /* slightly lighter than bg */
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); /* stronger shadow */
}

Handle images and media. Full-brightness images on a dark background can be visually jarring. Consider adding a subtle overlay or reducing brightness slightly:

[data-theme="dark"] img:not([data-no-dim]) {
  filter: brightness(0.9);
}

The data-no-dim attribute lets you opt specific images out — logos and icons that need to stay at full brightness.

Building the toggle component

The theme toggle should be simple, accessible, and instant. Here is a minimal implementation:

<button
  id="theme-toggle"
  type="button"
  aria-label="Toggle dark mode"
  class="theme-toggle"
>
  <svg class="icon-sun" viewBox="0 0 24 24" width="20" height="20">
    <circle cx="12" cy="12" r="5" fill="currentColor" />
    <g stroke="currentColor" stroke-width="2" stroke-linecap="round">
      <line x1="12" y1="1" x2="12" y2="3" />
      <line x1="12" y1="21" x2="12" y2="23" />
      <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
      <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
      <line x1="1" y1="12" x2="3" y2="12" />
      <line x1="21" y1="12" x2="23" y2="12" />
      <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
      <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
    </g>
  </svg>
  <svg class="icon-moon" viewBox="0 0 24 24" width="20" height="20">
    <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="currentColor" />
  </svg>
</button>
.theme-toggle {
  background: none;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  padding: 0.5rem;
  cursor: pointer;
  color: var(--color-text-secondary);
  display: flex;
  align-items: center;
  justify-content: center;
  transition: color 0.2s, border-color 0.2s;
}

.theme-toggle:hover {
  color: var(--color-text-primary);
  border-color: var(--color-border-strong);
}

[data-theme="light"] .icon-moon { display: none; }
[data-theme="dark"] .icon-sun { display: none; }

Show the sun icon in dark mode (clicking will switch to light) and the moon icon in light mode (clicking will switch to dark). This is the convention users expect.

UI design color palette with carefully selected hues and contrast ratios

Handling transitions

When the user toggles themes, an instant swap can feel abrupt. A short CSS transition smooths it out:

:root {
  transition: background-color 0.2s ease, color 0.2s ease;
}

*, *::before, *::after {
  transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}

One caveat: this transition should only apply when the user actively toggles the theme. On initial page load, there should be no transition — it should be instant. You can handle this by adding a class after the initial render:

// After the page is fully loaded and theme is applied
requestAnimationFrame(() => {
  document.documentElement.classList.add('theme-transitions-enabled');
});
.theme-transitions-enabled *,
.theme-transitions-enabled *::before,
.theme-transitions-enabled *::after {
  transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}

This ensures the initial theme application is instant (no awkward fade from light to dark on load), but subsequent toggles are smooth.

Testing dark mode

A checklist we run through before shipping any dark mode implementation:

  • All text meets WCAG AA contrast ratios (4.5:1 for body text, 3:1 for large text) in both themes.
  • Form inputs, borders, and focus rings are visible against dark backgrounds.
  • Images and illustrations do not look broken or overly bright.
  • The theme persists across page navigations and browser restarts.
  • No flash of wrong theme on initial load, including hard refresh.
  • System preference changes are respected when no manual preference is saved.
  • The toggle is keyboard accessible and screen-reader friendly.
  • Third-party embeds (YouTube, maps, code blocks) do not clash with the dark theme.

Dark mode adds real complexity to your frontend, but with design tokens, it becomes manageable. The investment in a solid token architecture pays off not just for dark mode but for any future theming — high contrast modes, brand variations, or client-specific themes.

If you want help implementing a design token system or dark mode for your product, reach out at [email protected]. We have built this pattern enough times to know where the pitfalls are.