Dark Mode Implementation: CSS Custom Properties, Color Schemes, and UX | SoniNow Blog

Limited TimeLearn More

dark modecssuxdesignaccessibility

Dark Mode Implementation: CSS Custom Properties, Color Schemes, and UX

Published

2026-06-23

Read Time

4 mins

Dark Mode Implementation: CSS Custom Properties, Color Schemes, and UX

Dark mode is no longer a niche feature—users expect it. Operating systems have offered system-wide dark mode for years, and users increasingly prefer reduced luminance interfaces, especially for reading and late-night browsing. Implementing dark mode correctly requires more than inverting colors.

CSS Custom Properties: The Foundation

CSS custom properties enable clean theme switching. Define all color values as custom properties on :root, then override them for dark mode:

:root {
  /* Light theme (default) */
  --color-surface: #ffffff;
  --color-surface-secondary: #f5f5f5;
  --color-text-primary: #1a1a1a;
  --color-text-secondary: #525252;
  --color-text-muted: #a3a3a3;
  --color-border: #e5e5e5;
  --color-brand: #0ea5e9;
  --color-brand-hover: #0284c7;

  /* Shadows for light mode */
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
}

[data-theme="dark"] {
  --color-surface: #171717;
  --color-surface-secondary: #262626;
  --color-text-primary: #fafafa;
  --color-text-secondary: #d4d4d4;
  --color-text-muted: #737373;
  --color-border: #404040;
  --color-brand: #38bdf8;
  --color-brand-hover: #7dd3fc;

  /* Softer shadows for dark mode — dark surfaces need less shadow */
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
}

Use the properties everywhere in your stylesheets. Switching themes becomes a single attribute change:

.card {
  background: var(--color-surface-secondary);
  color: var(--color-text-primary);
  border: 1px solid var(--color-border);
  box-shadow: var(--shadow-sm);
}

prefers-color-scheme: Respecting System Preferences

The prefers-color-scheme media query detects the user's OS-level theme preference. Apply it alongside your toggle to respect system settings:

/* Respect system dark mode when no manual override exists */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-surface: #171717;
    /* ... all dark mode variables */
  }
}

But giving users manual control is essential. A toggle with three states works best:

function initTheme() {
  const stored = localStorage.getItem('theme'); // 'light', 'dark', or null

  if (stored) {
    document.documentElement.setAttribute('data-theme', stored);
  } else {
    // No stored preference, follow system
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
  }
}

// Update theme when system preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  if (!localStorage.getItem('theme')) {
    document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
  }
});

function toggleTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
  localStorage.setItem('theme', theme);
}

Avoiding Flash of Wrong Theme

When the page loads, the browser paints before JavaScript runs. A flash of the light theme followed by a switch to dark mode is jarring. Fix it with an inline script in the <head>:

<head>
  <script>
    (function() {
      const stored = localStorage.getItem('theme');
      if (stored) {
        document.documentElement.setAttribute('data-theme', stored);
      } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
        document.documentElement.setAttribute('data-theme', 'dark');
      }
    })();
  </script>
</head>

This runs synchronously before the first paint, so the correct theme is applied immediately.

Images and Media in Dark Mode

Images designed for light mode often look wrong in dark mode—bright backgrounds in images stand out against dark UI. Use CSS filters or provide dark-mode variants:

[data-theme="dark"] img {
  /* Slightly reduced brightness and increased contrast for photos */
  filter: brightness(0.85) contrast(1.1);
}

[data-theme="dark"] .logo {
  /* Replace logo with inverted version */
  content: url('/logo-dark.svg');
}

For more control, provide explicit dark-mode image sources:

<picture>
  <source srcset="/diagram-dark.svg" media="(prefers-color-scheme: dark)" />
  <source srcset="/diagram-light.svg" media="(prefers-color-scheme: light)" />
  <img src="/diagram-light.svg" alt="Architecture diagram" />
</picture>

Smooth Theme Transitions

Instantaneous theme changes are visually jarring. Add a transition on color properties for a smooth experience:

*,
*::before,
*::after {
  transition: background-color 0.3s ease,
              color 0.3s ease,
              border-color 0.3s ease,
              box-shadow 0.3s ease;
}

Use a class toggle to disable transitions when the page first loads, preventing animations on initial paint:

/* Disable transitions during initial load */
.theme-ready *,
.theme-ready *::before,
.theme-ready *::after {
  transition: none !important;
}
// Enable transitions after first paint
requestAnimationFrame(() => {
  document.documentElement.classList.remove('theme-ready');
});

Testing Dark Mode

Dark mode introduces new accessibility failure modes. Test all combinations:

  • Color contrast in both themes (especially muted text on dark surfaces)
  • Focus indicators visible against both backgrounds
  • Form field borders legible in both themes
  • Charts and data visualization with dual palettes
// Automated contrast check for both themes
async function checkThemeContrast(theme) {
  document.documentElement.setAttribute('data-theme', theme);
  const results = await new AxeBuilder({ page }).analyze();
  const contrastIssues = results.violations.filter(v => v.id === 'color-contrast');
  console.log(`${theme}: ${contrastIssues.length} contrast issues`);
}

Dark mode done well is invisible—users get a comfortable reading experience without thinking about it. Done poorly, it creates readability problems and design inconsistencies. CSS custom properties, system preference detection, and fallback-handling make it a robust feature rather than an afterthought.

Our custom UI/UX design team implements dark mode with full accessibility testing, ensuring a polished experience in both themes.