🚀AdvancedAdvanced CSS Architecture

CSS Dark Mode and Color Schemes

Implement system-aware dark mode with prefers-color-scheme, custom property swaps, color-scheme, and user-toggleable themes.

Overview

Dark mode in CSS is driven by two mechanisms: **1. OS-level detection with prefers-color-scheme:** ```css @media (prefers-color-scheme: dark) { :root { --bg: #0f172a; --text: #f1f5f9; } } ``` **2. color-scheme property (tells the browser to style native UI):** ```css :root { color-scheme: light dark; } ``` The best approach is a token-swap architecture: define semantic tokens once and swap their values in dark mode. The components reference only tokens, never raw colors. ```css :root { --bg-page: oklch(0.98 0.005 260); --text-body: oklch(0.15 0.02 260); } @media (prefers-color-scheme: dark) { :root { --bg-page: oklch(0.14 0.02 260); --text-body: oklch(0.92 0.005 260); } } .page { background: var(--bg-page); color: var(--text-body); } ``` **User-toggleable dark mode** uses a data attribute: ```css [data-theme="dark"] { --bg-page: oklch(0.14 0.02 260); } ```

Key Points

  • •@media (prefers-color-scheme: dark) matches when the OS is in dark mode
  • •color-scheme: light dark tells the browser to adapt scrollbars, inputs, and backgrounds natively
  • •Token-swap architecture keeps components clean: they reference semantic tokens, not raw colors
  • •data-theme="dark" on the <html> element enables JavaScript-driven theme toggling
  • •color-scheme on individual elements scopes the native styling (e.g., a dark card inside a light page)
  • •forced-colors: active is a separate media query for high-contrast mode
  • •The prefers-contrast media query detects when users prefer higher contrast
  • •Combining system preference with a user override requires careful localStorage and class management

Examples

Token-swap dark mode architecture

:root {
  /* Light mode tokens */
  --bg-page:       #ffffff;
  --bg-surface:    #f8fafc;
  --bg-elevated:   #ffffff;
  --border-subtle: #e5e7eb;
  --text-primary:  #0f172a;
  --text-secondary:#6b7280;
  --brand:         #6366f1;
  --brand-hover:   #4f46e5;

  color-scheme: light dark;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg-page:       #0f172a;
    --bg-surface:    #1e293b;
    --bg-elevated:   #334155;
    --border-subtle: #334155;
    --text-primary:  #f1f5f9;
    --text-secondary:#94a3b8;
    --brand:         #818cf8;
    --brand-hover:   #a5b4fc;
  }
}

/* Components only reference tokens */
.card {
  background: var(--bg-elevated);
  border: 1px solid var(--border-subtle);
  color: var(--text-primary);
}

Components never reference raw colors. Swapping tokens in dark mode automatically updates every component.

User-toggled theme with data attribute

/* System default */
:root {
  --bg: #ffffff;
  --text: #0f172a;
  color-scheme: light;
}

/* Dark mode via OS */
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0f172a;
    --text: #f1f5f9;
    color-scheme: dark;
  }
}

/* User override: light forced */
[data-theme="light"] {
  --bg: #ffffff;
  --text: #0f172a;
  color-scheme: light;
}

/* User override: dark forced */
[data-theme="dark"] {
  --bg: #0f172a;
  --text: #f1f5f9;
  color-scheme: dark;
}

JavaScript sets data-theme on <html>. The data attribute has higher specificity in the cascade than the @media fallback, so it overrides the system preference.

Dark mode images and media

/* Invert logos and diagrams in dark mode */
@media (prefers-color-scheme: dark) {
  .logo-img {
    filter: invert(1) hue-rotate(180deg);
  }

  /* Reduce brightness for photos */
  img:not([src*=".svg"]) {
    filter: brightness(0.85);
  }
}

/* Use the picture element for art direction */
/* HTML: <picture>
     <source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)">
     <img src="logo-light.svg" alt="Logo">
   </picture> */

/* Target dark mode in CSS images */
.hero {
  background-image: url('hero-light.jpg');
}

@media (prefers-color-scheme: dark) {
  .hero {
    background-image: url('hero-dark.jpg');
  }
}

Respecting forced-colors (high contrast mode)

/* ButtonText, ButtonFace, etc. are system color keywords */
@media (forced-colors: active) {
  .btn {
    /* Let the OS control colors entirely */
    background: ButtonFace;
    color: ButtonText;
    border: 2px solid ButtonText;
    forced-color-adjust: auto;
  }

  /* Mark decorative elements to be ignored */
  .decorative-icon {
    forced-color-adjust: none;
    opacity: 0; /* or display: none */
  }
}

forced-colors: active (Windows High Contrast Mode) overrides CSS colors with OS system colors. Use system color keywords to ensure your UI is still usable.

Common Mistakes

  • Using raw color values in component styles instead of semantic tokens (makes dark mode changes scattered)
  • Not setting color-scheme, causing native UI elements (scrollbars, inputs, select) to stay light in dark mode
  • Overriding user theme preference with a hardcoded light theme without providing an escape option
  • Forgetting that high-contrast mode (forced-colors) is separate from dark mode and needs its own consideration

Note

Use oklch() for your token values. Since oklch is perceptually uniform, you can derive good dark mode variants by adjusting lightness: a light-mode color at L=0.55 becomes the dark-mode text at L=0.80 and dark-mode background at L=0.14.

Exercises

Write CSS to style the HTML scaffold. The preview updates live — when it looks right, mark as done.

Loading editor…

Completed: 0 / 2 exercises

Knowledge Check

Question 1 of 3

What does color-scheme: light dark accomplish?