← Back to Blog
React2026-05-13·73 min read

Why Your Dark Mode Looks Weird (And How to Fix It)

By Yue Geng

Bridging the Gap Between CSS Theme Toggles and Native Browser Rendering

Current Situation Analysis

Modern web applications increasingly rely on manual theme switchers to give users explicit control over light and dark presentations. While custom component styling has become highly sophisticated through CSS variables, utility frameworks, and design tokens, a persistent rendering gap remains: native browser UI elements frequently ignore manual theme overrides. Scrollbars, <select> dropdowns, date pickers, and form controls often retain their operating system default appearance, creating a jarring visual disconnect between the application shell and the browser's native chrome.

This problem is routinely overlooked because development workflows prioritize custom component styling over browser-level rendering hints. Engineers typically focus on overriding background colors, text hues, and border styles using class-based or data-attribute selectors. The color-scheme CSS property, which directly instructs the browser's internal theming engine, is frequently treated as optional or misunderstood as purely decorative. Many teams assume that setting color-scheme: light dark on the root element will automatically sync with any manual toggle, but this is fundamentally incorrect. That declaration explicitly delegates control back to the operating system, bypassing application-level state entirely.

Browser rendering pipelines treat color-scheme as a high-priority hint. When set to light dark, the browser continuously monitors the OS preference and applies native UI styling accordingly, regardless of CSS class changes or JavaScript state updates. This behavior is documented across Chromium, Gecko, and WebKit engines. The mismatch becomes immediately visible in production when users manually switch themes: custom components update instantly, but native controls remain locked to the system default. The result is a fractured interface that signals incomplete engineering, even when the underlying logic is sound.

WOW Moment: Key Findings

The core insight lies in understanding how the browser resolves theme hints versus application state. When you decouple color-scheme from OS-level media queries and bind it directly to your toggle mechanism, native UI synchronization becomes deterministic. The following comparison illustrates the rendering behavior across three common implementation strategies:

Approach Native UI Sync JS Overhead CSS Complexity User Control
OS-Only (prefers-color-scheme + color-scheme: light dark) Automatic Zero Low None
Manual Toggle (Unsynced color-scheme) Broken Low Medium Full
Manual Toggle (Explicit color-scheme binding) Synchronized Low Medium Full

This finding matters because it shifts theme implementation from a reactive patching process to a declarative architecture. By explicitly mapping color-scheme to application state, you eliminate visual fragmentation without introducing significant performance penalties. The browser's native rendering engine handles the heavy lifting once the hint is correctly provided, reducing the need for custom scrollbar styling or form control overrides. This approach also future-proofs the interface against browser updates that may change default native styling, since the rendering engine remains the source of truth for chrome elements.

Core Solution

Implementing a synchronized theme toggle requires aligning three layers: state management, CSS rendering hints, and persistence. The architecture should prioritize CSS-driven rendering for performance, while JavaScript handles state transitions and storage.

Step 1: Define the Theme State Structure

Avoid class-based toggles in favor of data attributes. Data attributes provide higher specificity, cleaner DOM querying, and better compatibility with modern CSS selectors.

interface ThemeState {
  mode: 'light' | 'dark';
  source: 'system' | 'manual';
}

Step 2: Implement the Toggle Controller

Create a dedicated module to manage state transitions, persistence, and DOM updates. This isolates theme logic from application components.

class ThemeController {
  private readonly STORAGE_KEY = 'app_theme_preference';
  private readonly ROOT = document.documentElement;

  constructor() {
    this.initialize();
  }

  private initialize(): void {
    const stored = localStorage.getItem(this.STORAGE_KEY);
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    
    const initialMode = stored 
      ? (stored === 'dark' ? 'dark' : 'light')
      : (prefersDark ? 'dark' : 'light');

    this.applyTheme(initialMode, 'system');
  }

  public toggle(): void {
    const current = this.ROOT.getAttribute('data-theme');
    const next = current === 'dark' ? 'light' : 'dark';
    this.applyTheme(next, 'manual');
    localStorage.setItem(this.STORAGE_KEY, next);
  }

  private applyTheme(mode: 'light' | 'dark', source: 'system' | 'manual'): void {
    this.ROOT.setAttribute('data-theme', mode);
    this.ROOT.setAttribute('data-theme-source', source);
    
    // Explicitly sync the browser rendering hint
    this.ROOT.style.colorScheme = mode;
  }
}

Step 3: Bind color-scheme in CSS

While the JavaScript controller updates the inline style, CSS should also enforce the rendering hint to prevent cascade conflicts and ensure SSR compatibility.

:root {
  color-scheme: light;
  --bg-primary: #ffffff;
  --text-primary: #111827;
}

[data-theme="dark"] {
  color-scheme: dark;
  --bg-primary: #0f172a;
  --text-primary: #f8fafc;
}

/* Fallback for system preference when no manual override exists */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    color-scheme: dark;
    --bg-primary: #0f172a;
    --text-primary: #f8fafc;
  }
}

Architecture Decisions and Rationale

Why explicit color-scheme assignment over light dark?
The light dark value instructs the browser to continuously poll the OS preference. When a manual toggle exists, this creates a race condition between application state and system settings. Explicit assignment removes ambiguity and guarantees that native UI matches the declared theme.

Why inline style assignment in JavaScript?
Inline styles bypass CSS specificity conflicts and apply immediately during the JavaScript execution phase. This prevents the brief flash where native controls render in the wrong scheme before CSS rules cascade.

Why separate data-theme and data-theme-source?
Tracking the source (system vs manual) enables advanced behaviors, such as automatically reverting to OS preference when a user clears their manual override, or disabling the toggle when system preference is locked by enterprise policy.

Pitfall Guide

1. Leaving color-scheme: light dark on the Root Element

Explanation: This declaration tells the browser to ignore application state and follow the OS. Manual toggles will update custom components but leave native UI unchanged.
Fix: Replace with explicit color-scheme: light or color-scheme: dark tied to your theme attribute.

2. Flash of Unstyled Content (FOUC) on Initial Load

Explanation: If theme logic runs in a deferred script, the browser renders with default light styling before JavaScript executes. Users see a brief white flash before dark mode applies.
Fix: Inject a minimal synchronous script in the <head> that reads localStorage and sets data-theme and color-scheme before the first paint.

<script>
  (function() {
    const theme = localStorage.getItem('app_theme_preference') || 
                  (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    document.documentElement.setAttribute('data-theme', theme);
    document.documentElement.style.colorScheme = theme;
  })();
</script>

3. Ignoring System Preference Changes During Runtime

Explanation: Users may change their OS theme while the application is open. If your app only respects the initial load state, it becomes out of sync.
Fix: Attach a listener to prefers-color-scheme changes, but only apply them when data-theme-source is system.

window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  if (this.ROOT.getAttribute('data-theme-source') === 'system') {
    this.applyTheme(e.matches ? 'dark' : 'light', 'system');
  }
});

4. Overriding color-scheme with Utility Frameworks

Explanation: Frameworks like Tailwind may generate conflicting rules or reset styles that strip inline color-scheme declarations during build processes.
Fix: Use CSS custom properties to bridge framework utilities and native hints, or configure the framework to preserve critical rendering attributes.

5. Assuming Shadow DOM and Iframes Inherit color-scheme

Explanation: color-scheme does not automatically propagate into shadow roots or cross-origin iframes. Native controls inside these boundaries will retain their default styling.
Fix: Explicitly set color-scheme on shadow host elements or document the limitation in your design system. For iframes, pass theme state via postMessage or URL parameters.

6. Conflicting Specificity with CSS Reset Libraries

Explanation: Aggressive resets may apply color-scheme: light globally, overriding your theme declarations.
Fix: Increase specificity using attribute selectors or apply color-scheme at the component level where resets are scoped.

7. Treating color-scheme as a Styling Property

Explanation: color-scheme is a rendering hint, not a visual style. It does not change colors directly; it tells the browser which internal palette to use for native controls.
Fix: Always pair color-scheme with explicit CSS variable overrides for custom elements. The property only affects browser-chrome, not your components.

Production Bundle

Action Checklist

  • Replace color-scheme: light dark with explicit light or dark values tied to theme state
  • Inject a synchronous head script to prevent FOUC on initial load
  • Use data attributes instead of classes for theme toggling to improve specificity
  • Implement a ThemeController module to centralize state, persistence, and DOM updates
  • Add a listener for prefers-color-scheme changes to handle runtime OS switches
  • Audit shadow DOM boundaries and iframe integrations for theme propagation gaps
  • Verify that CSS reset or utility frameworks do not strip inline color-scheme declarations
  • Test native form controls, scrollbars, and date pickers across Chromium, Gecko, and WebKit

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Static documentation site OS-only (prefers-color-scheme + color-scheme: light dark) Zero JS, respects user preference, minimal maintenance Low
SaaS application with user preferences Manual toggle with explicit color-scheme binding Required for account-level settings, ensures consistency Medium
Design system component library Dual-mode CSS variables + color-scheme hints Enables preview in both modes, maintains native control accuracy Medium
Enterprise/internal tool OS-only with admin override capability Reduces support tickets, aligns with corporate device policies Low
Mobile-first progressive web app Manual toggle + color-scheme + battery-aware fallback Preserves battery on OLED screens, respects user control High

Configuration Template

/* theme.css */
:root {
  color-scheme: light;
  --surface: #ffffff;
  --text: #0f172a;
  --border: #e2e8f0;
}

[data-theme="dark"] {
  color-scheme: dark;
  --surface: #0b1120;
  --text: #f1f5f9;
  --border: #1e293b;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    color-scheme: dark;
    --surface: #0b1120;
    --text: #f1f5f9;
    --border: #1e293b;
  }
}

/* Apply to custom components */
.card {
  background-color: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
}
// theme-controller.ts
export class ThemeController {
  private static instance: ThemeController;
  private readonly root = document.documentElement;
  private readonly storageKey = 'ui_theme_v1';

  private constructor() {
    this.hydrate();
    this.listenToSystemChanges();
  }

  static getInstance(): ThemeController {
    if (!ThemeController.instance) {
      ThemeController.instance = new ThemeController();
    }
    return ThemeController.instance;
  }

  private hydrate(): void {
    const stored = localStorage.getItem(this.storageKey);
    const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    const mode = stored ? (stored === 'dark' ? 'dark' : 'light') : (systemDark ? 'dark' : 'light');
    this.set(mode, stored ? 'manual' : 'system');
  }

  public toggle(): void {
    const current = this.root.getAttribute('data-theme');
    const next = current === 'dark' ? 'light' : 'dark';
    this.set(next, 'manual');
    localStorage.setItem(this.storageKey, next);
  }

  private set(mode: 'light' | 'dark', source: 'system' | 'manual'): void {
    this.root.setAttribute('data-theme', mode);
    this.root.setAttribute('data-theme-source', source);
    this.root.style.colorScheme = mode;
  }

  private listenToSystemChanges(): void {
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
      if (this.root.getAttribute('data-theme-source') === 'system') {
        this.set(e.matches ? 'dark' : 'light', 'system');
      }
    });
  }
}

Quick Start Guide

  1. Add the head script: Place the synchronous theme initialization snippet in your HTML <head> to prevent rendering flashes.
  2. Define CSS variables: Map your design tokens to :root and [data-theme="dark"] selectors, ensuring color-scheme is explicitly set in both.
  3. Instantiate the controller: Call ThemeController.getInstance() during application bootstrap to hydrate state and attach listeners.
  4. Bind the toggle UI: Attach a click handler to your theme switcher button that calls controller.toggle().
  5. Verify native controls: Open a page with <select>, date inputs, and long content to confirm scrollbars and form elements match the active theme across browsers.