FNA Technology Header Logo
ServicesWorkAboutBlog
[ start a project ]
FNA Technology Footer Logo

Transforming your digital vision into reality. Software, AI, web & mobile — built for outcomes.

[email protected] footer link+91 8879510299 footer link
Share page:
MAIN
HomeOur ServicesProjectsCompany Blog
COMPANY
About UsContact UsLinkedIn
LEGAL
Privacy PolicyTerms & Conditions
© 2026 FNA TECHNOLOGY LLP — ALL RIGHTS RESERVEDIndia · UK · Middle East
Dark Mode UI/UX: Best Practices in CodeImplement dark mode UI/UX correctly. Learn about color contrast ratios, CSS variables, system preference detection, and avoid common design mistakes.Business owners, developers, CTOsdark mode UI design, dark mode best practices, dark mode CSS implementation, WCAG dark mode contrast, prefers-color-scheme, design token dark mode, dark mode accessibilityFNA Technology
UI/UX

Dark Mode UI/UX: Best Practices in Code

May 4, 2026
8 min read
FNA Technology
Dark mode UI interface showing correct contrast ratios and color token system

The short version: Dark mode fails in predictable ways — pure black backgrounds, inverted images, broken contrast at real surface colors, and no state persistence across sessions. The fix is a token-based color system, correct contrast checking against actual surface values, and proper system preference detection with a user override mechanism.

Dark mode is not a color filter. That distinction sounds pedantic until you see what happens when a team treats it as one — the images look washed out, the contrast ratios are technically compliant against pure black but fail on real surfaces, and the toggle doesn't remember your choice between sessions.

Getting dark mode right requires decisions at the design token level, the CSS architecture level, and the JavaScript state level. This article covers all three, with the actual code patterns that hold up in production.

Why dark mode implementations fail

The most common mistakes, in rough order of frequency:

1. Using pure black as the base surface. #000000 backgrounds create extreme contrast against white text — higher than necessary, and fatiguing under extended use. Dark mode's purpose is reducing eye strain in low-light conditions. Harsh contrast defeats that purpose. Material Design 3 specifies #121212 as the baseline dark surface for good reason.

2. Inverting colors rather than theming them. CSS filter: invert(1) is not dark mode. It inverts images, breaks color meanings (red error states become cyan), and looks immediately wrong. Proper dark mode requires a separate token set, not a transformation of the light mode values.

3. Checking contrast against the wrong background. A common WCAG audit mistake: developers verify text contrast against #000000 and report a passing ratio. In production, the surface is #1A1A1A or #212121. The actual contrast ratio against those values is lower. Always check contrast against your real surface colors.

4. Missing state persistence. The user switches to dark mode. They navigate to another page. Light mode. This happens when the theme is set as a class on the <html> element via JavaScript on page load without reading localStorage first. A flash of incorrect theme on every page load.

5. No system preference detection. Users who have dark mode set at the OS level expect apps to respect that default, without manual configuration.

Color token architecture for dark mode

The pattern that scales is a two-layer token system: primitive tokens (actual hex values) and semantic tokens (contextual meaning). Components reference only semantic tokens. Theming changes only the semantic token values.

Code
/* Primitive tokens — the raw values */
:root {
  --color-grey-900: #0D0D0D;
  --color-grey-850: #141414;
  --color-grey-800: #1C1C1C;
  --color-grey-100: #F5F5F5;
  --color-grey-200: #E8E8E8;
  --color-blue-400: #60A5FA;
  --color-blue-600: #2563EB;
  --color-red-400: #F87171;
  --color-red-600: #DC2626;
}

/* Semantic tokens — light mode defaults */
:root {
  --color-surface-base: var(--color-grey-100);
  --color-surface-elevated: #FFFFFF;
  --color-text-primary: #111111;
  --color-text-secondary: #555555;
  --color-border: #E0E0E0;
  --color-accent: var(--color-blue-600);
  --color-error: var(--color-red-600);
}

/* Semantic tokens — dark mode overrides */
[data-theme="dark"] {
  --color-surface-base: var(--color-grey-900);
  --color-surface-elevated: var(--color-grey-800);
  --color-text-primary: #F0F0F0;
  --color-text-secondary: #A0A0A0;
  --color-border: #2E2E2E;
  --color-accent: var(--color-blue-400);
  --color-error: var(--color-red-400);
}

With this setup, a component that uses var(--color-surface-base) for its background automatically adapts to dark mode. No component-level dark mode logic needed.

Elevation in dark mode: the surface layering system

In light mode, elevation is shown with shadow. In dark mode, shadows are nearly invisible against dark surfaces. The correct approach is to increase surface lightness as elevation increases — lighter surfaces appear closer to the user.

Code
[data-theme="dark"] {
  /* Base surface */
  --elevation-0: #121212;

  /* Cards, dropdowns */
  --elevation-1: #1E1E1E;

  /* Modals, drawers */
  --elevation-2: #242424;

  /* Tooltips, toasts */
  --elevation-3: #2C2C2C;
}

The lightness step between levels is typically 5–8%. This matches the Material Design dark theme elevation overlay specification. Going smaller makes the levels indistinguishable; going larger creates flat-looking interfaces where everything appears to be the same layer.

Contrast ratios: what WCAG actually requires

WCAG 2.1 AA requires:

  • 4.5:1 for normal text (under 18pt or 14pt bold)
  • 3:1 for large text (18pt+ or 14pt+ bold) and UI components

Dark mode introduces a specific pitfall: light-colored text on dark-colored backgrounds can still fail contrast if the surface isn't dark enough or the text isn't light enough.

Code
Surface: #1C1C1C (dark mode elevated surface)
Text: #E0E0E0 (common secondary text color)

Relative luminance of #1C1C1C: 0.0106
Relative luminance of #E0E0E0: 0.7215

Contrast ratio = (0.7215 + 0.05) / (0.0106 + 0.05) = 12.6:1 ✓ passes AA and AAA

Compare with a common mistake:

Code
Surface: #1C1C1C
Text: #888888 (medium grey used as "secondary" text in dark mode)

Relative luminance of #888888: 0.2158
Contrast ratio = (0.2158 + 0.05) / (0.0106 + 0.05) = 4.4:1 ✗ fails AA by 0.1

That #888888 secondary text color fails. It's a ratio most designers eyeball as "looks fine" but doesn't pass. Use the WebAIM Contrast Checker with your actual surface and text values — not assumed ones.

System preference detection and state management

Code
// Read system preference
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)');

// Read stored user preference
const storedTheme = localStorage.getItem('theme');

// Apply theme: stored preference wins over system default
function applyTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
  localStorage.setItem('theme', theme);
}

// Initial load — apply before first paint to avoid flash
const initialTheme = storedTheme || (systemPrefersDark.matches ? 'dark' : 'light');
applyTheme(initialTheme);

// Listen for system preference changes (user changes OS setting)
systemPrefersDark.addEventListener('change', (e) => {
  // Only apply system change if user hasn't made an explicit choice
  if (!localStorage.getItem('theme')) {
    applyTheme(e.matches ? 'dark' : 'light');
  }
});

The critical detail: apply the theme before the first paint. In Next.js, this means running the theme detection in a <script> tag inside <head>, before the React hydration. Otherwise, the page flashes light mode before switching to dark on every load.

Code
// _document.tsx or app/layout.tsx <head>
<script dangerouslySetInnerHTML={{
  __html: `
    (function() {
      const stored = localStorage.getItem('theme');
      const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const theme = stored || (systemDark ? 'dark' : 'light');
      document.documentElement.setAttribute('data-theme', theme);
    })();
  `
}} />

Image handling in dark mode

Photographs: reduce brightness slightly to prevent them standing out too harshly against dark surfaces.

Code
[data-theme="dark"] img {
  filter: brightness(0.85);
}

SVG icons: if they use hardcoded fill colors, they will not adapt automatically. Use currentColor so they inherit the text color of their parent.

Code
<!-- Wrong — hardcoded fill -->
<path fill="#000000" d="..." />

<!-- Correct — inherits currentColor -->
<path fill="currentColor" d="..." />

Logos and brand marks that use black on transparent backgrounds need a separate dark-mode version, or they will disappear. A <picture> element with a media query handles this:

Code
<picture>
  <source srcset="/logo-white.svg" media="(prefers-color-scheme: dark)" />
  <img src="/logo-dark.svg" alt="Company logo" />
</picture>

Note: this approach reads from the system preference, not from your stored user preference. For applications with a manual toggle, serving the correct logo version requires JavaScript to swap the src attribute based on the active theme.

What to check before shipping dark mode

  • Background surfaces use dark greys, not pure black
  • Contrast checked against real surface values (not #000000) using WebAIM or Figma contrast plugin
  • Elevation system uses lightness stepping, not shadows
  • All icons and illustrations use currentColor or have dark mode variants
  • Logos with black fill have white/light alternatives for dark mode
  • Theme applied before first paint to avoid flash
  • User preference persisted in localStorage
  • System preference changes respected when no explicit user choice exists
  • Semantic color tokens used throughout — no hardcoded hex values in component styles

Who needs a full token system vs. a simpler approach

If you're building a design system used across multiple products or teams, the two-layer token architecture is worth the setup cost. Changes to dark mode semantics propagate everywhere automatically.

For a single web application or marketing site, a simpler approach works fine: one set of CSS custom properties at :root, overridden in a prefers-color-scheme media query. No data-theme attribute needed unless you require a manual toggle that overrides the system setting.

The limitation of pure media-query dark mode: you can't give users a manual toggle without JavaScript. If users expect to switch themes independently of their OS setting — and most do — you need the JavaScript state layer described above.

For dark mode decisions that intersect with mobile app UI, the accessibility considerations in app development article covers how these same contrast and preference-detection principles apply in native iOS and Android contexts.

Frequently Asked Questions

No. Pure black creates harsh contrast against white text that causes eye strain under extended use — the opposite of what dark mode is supposed to do. Material Design 3 uses #121212 as its baseline dark surface. Most well-regarded dark themes sit between #0D0D0D and #1E1E1E for base surfaces, with elevated layers stepping up in 5–8% lightness increments.

The same as light mode: 4.5:1 for normal text, 3:1 for large text and UI components under WCAG 2.1 AA. The common mistake is checking contrast against a pure black background — the calculated ratio will be higher than what renders on a real dark surface at #121212 or #1E1E1E. Always check contrast against your actual surface colors.

Photographs generally don't need dark mode variants. Reduce their brightness slightly using CSS — filter: brightness(0.85) — to prevent them from appearing too stark against dark surfaces. Icons, illustrations, and logos that use solid black on transparent backgrounds need separate dark mode versions, or they will become invisible. SVGs are easiest to theme dynamically via CSS currentColor.

Use CSS custom properties (variables) with a token system. Define your semantic color tokens at the :root level, then override them inside a [data-theme='dark'] selector or a prefers-color-scheme: dark media query. Never hardcode color values in component styles — every component that references a token automatically adapts when the token values change.

Use the prefers-color-scheme CSS media query to set the initial theme, and the window.matchMedia API in JavaScript to detect changes at runtime. Store the user's explicit preference in localStorage so it persists across sessions and overrides the system default when the user has actively chosen a theme.

#dark mode UI design#dark mode best practices#dark mode CSS implementation#WCAG dark mode contrast#prefers-color-scheme#design token dark mode#dark mode accessibility
Share this article:
FNA Technology

Written by

FNA Technology

Team Member at FNA Technology

FNA Technology is a software development company specializing in AI, mobile apps, and web solutions.

Work with us