Dark Mode with prefers-color-scheme

    Lesson 32 โ€ข Advanced Track

    What You'll Learn

    • Detect system dark mode with the prefers-color-scheme media query
    • Build a manual three-way toggle (System / Light / Dark)
    • Structure CSS variables for seamless light/dark switching
    • Persist user theme preference with localStorage
    • Handle images, shadows, and icons in dark mode

    ๐Ÿ’ก Real-World Analogy

    Dark mode is like automatic headlights โ€” they turn on when it gets dark (system preference). But you can also flip the switch manually (user toggle). A well-built dark mode supports both, and remembers your choice so you don't have to switch every time you get in the car.

    Understanding Dark Mode

    The prefers-color-scheme media query reads the operating system's light/dark setting. It's supported in all modern browsers (Chrome, Firefox, Safari, Edge) and requires no JavaScript. When the user changes their OS appearance, the browser immediately applies the matching CSS.

    The best strategy combines three layers: (1) default light values in :root, (2) dark overrides inside @media (prefers-color-scheme: dark), and (3) an optional [data-theme] attribute selector for manual user control that overrides both.

    CSS Variables Strategy

    VariableLight ValueDark ValuePurpose
    --bg#ffffff#121212Page background
    --surface#f5f5f5#1e1e1eCard/panel background
    --text#1a1a1a#e0e0e0Primary text
    --primary#1976D2#64B5F6Brand colour (lighter in dark)
    --border#e0e0e0#333333Dividers and borders
    --shadowrgba(0,0,0,0.08)rgba(0,0,0,0.4)Box shadows (stronger in dark)

    1. Basic prefers-color-scheme Detection

    Try it Yourself ยป
    Code Preview
    <!DOCTYPE html>
    <html>
    <head><style>
    :root {
        --bg: #ffffff; --surface: #f5f5f5; --text: #1a1a1a;
        --text-muted: #666; --primary: #1976D2; --border: #e0e0e0;
        --shadow: 0 2px 8px rgba(0,0,0,0.08);
    }
    @media (prefers-color-scheme: dark) {
        :root {
            --bg: #121212; --surface: #1e1e1e; --text: #e0e0e0;
            --text-muted: #999; --primary: #64B5F6; --border: #333;
            --shadow: 0 2px 8px rgba(0,0,0,0.4);
        }
    }
    body { font-family: system-ui, sans-serif; background: var(--bg);
    ...

    How variable cascading works: CSS custom properties follow the normal cascade. When @media (prefers-color-scheme: dark) activates, it re-declares the same variables on :root. Every element using var(--bg) instantly reads the new value โ€” no class toggling needed.

    2. Manual Three-Way Toggle

    Try it Yourself ยป
    Code Preview
    <!DOCTYPE html>
    <html>
    <head><style>
    :root {
        --bg: #ffffff; --surface: #f5f5f5; --text: #1a1a1a;
        --text-muted: #666; --primary: #1976D2; --border: #e0e0e0;
    }
    @media (prefers-color-scheme: dark) {
        :root { --bg: #121212; --surface: #1e1e1e; --text: #e0e0e0; --text-muted: #999; --primary: #64B5F6; --border: #333; }
    }
    [data-theme="dark"] { --bg: #121212; --surface: #1e1e1e; --text: #e0e0e0; --text-muted: #999; --primary: #64B5F6; --border: #333; }
    [data-theme="light"] { --bg: #ffffff; --
    ...

    Step-by-Step: data-theme Override

    Why does [data-theme] override @media? Attribute selectors like [data-theme="dark"] have higher specificity than :root inside a media query. So when a user clicks "Dark", the attribute wins over the system preference โ€” giving users explicit control.

    The "System" button removes the attribute entirely, letting the media query take over again.

    3. Saving User Preference (localStorage)

    Try it Yourself ยป
    Code Preview
    <!DOCTYPE html>
    <html>
    <head><style>
    :root { --bg: #fff; --surface: #f5f5f5; --text: #1a1a1a; --muted: #666; --primary: #1976D2; --border: #e0e0e0; }
    [data-theme="dark"] { --bg: #121212; --surface: #1e1e1e; --text: #e0e0e0; --muted: #999; --primary: #64B5F6; --border: #333; }
    body { font-family: system-ui; background: var(--bg); color: var(--text); padding: 24px; transition: background 0.3s; }
    .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 24px
    ...

    Images & Media in Dark Mode

    Bright images can be jarring on dark backgrounds. Use filter: brightness(0.85) to subtly dim photos. For logos on transparent backgrounds, filter: invert(1) flips black to white. SVG icons should use currentColor for automatic colour inheritance.

    Shadows need adjustment too โ€” on dark surfaces, shadows must be darker and more opaque to create visible depth contrast.

    4. Adaptive Images & Shadows

    Try it Yourself ยป
    Code Preview
    <!DOCTYPE html>
    <html>
    <head><style>
    :root { --bg: #fff; --text: #1a1a1a; --muted: #666; --primary: #1976D2; --surface: #f5f5f5; --border: #e0e0e0; --shadow: 0 4px 12px rgba(0,0,0,0.1); --img-filter: none; }
    [data-theme="dark"] { --bg: #121212; --text: #e0e0e0; --muted: #999; --primary: #64B5F6; --surface: #1e1e1e; --border: #333; --shadow: 0 4px 12px rgba(0,0,0,0.5); --img-filter: brightness(0.85); }
    body { font-family: system-ui; background: var(--bg); color: var(--text); padding: 24px; transi
    ...

    When to Use This

    • Every modern website: Users expect dark mode. It reduces eye strain in low light and can save battery on OLED screens.
    • Content-heavy apps: Reading apps, documentation sites, dashboards benefit most from dark mode.
    • Branding consistency: Define your brand colours for both light and dark, don't let the browser guess.

    Common Mistakes

    • Using pure black (#000000) โ€” Pure black creates too much contrast against white text. Use #121212 or #1a1a2e for comfortable dark backgrounds.
    • Forgetting box-shadows โ€” Light-mode shadows (rgba(0,0,0,0.08)) are invisible on dark backgrounds. Increase opacity to 0.3-0.5.
    • Not testing transparent PNG logos โ€” A black logo on a transparent background disappears against a dark surface. Provide light/dark logo variants or use filter: invert(1).
    • Flash of wrong theme (FOWT) โ€” If you read localStorage after page render, users see a flash of light mode. Set data-theme in a blocking <script> in the <head>.
    • Hardcoding colours instead of variables โ€” Every colour that changes between modes must be a CSS variable. Using color: #333 directly means it won't adapt.
    • Ignoring focus ring visibility โ€” Default focus rings may be invisible on dark backgrounds. Test keyboard navigation in both modes.

    ๐ŸŽ‰ Lesson Complete

    • โœ… prefers-color-scheme: dark detects OS dark mode without JavaScript
    • โœ… CSS variables make theme switching a simple value override
    • โœ… [data-theme] attribute enables manual user control with higher specificity
    • โœ… Three-way toggle: System / Light / Dark gives users full control
    • โœ… Save preference in localStorage and apply before render to avoid flash
    • โœ… Dim images with brightness(0.85), flip logos with invert(1)
    • โœ… Increase shadow opacity on dark surfaces for visible depth
    • โœ… Always test both modes including focus rings and form elements

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy Policy โ€ข Terms of Service