Dark Mode

Dark mode is an alternate colour theme that the prefers-color-scheme media query detects from the user's operating-system setting, letting you swap to darker backgrounds and lighter text automatically — best implemented with CSS variables so a single toggle can override the system default.

Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.

Part of the free HTML & CSS course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

By the end of this lesson you'll build a page that follows the operating system's dark mode automatically, adds a Light / Dark / System toggle that overrides it, remembers the choice across refreshes, and loads with no flash of the wrong theme .

Dark mode is like the automatic headlights in a car. By default they switch on when it gets dark outside — that's the system preference ( prefers-color-scheme ) reading the world for you. But there's also a manual switch on the dash: you can force the lights on or off whenever you like — that's the user toggle ( data-theme ), and because you turned it by hand it overrides the automatic sensor. A well-built car also remembers the switch position next time you start it ( localStorage ), and the dashboard doesn't blind you with a bright flash before settling — the lights are already at the right level the instant you turn the key (no flash of the wrong theme ).

A media query is a CSS block that only applies when a condition is true. @media (prefers-color-scheme: dark) is true when the user has set their operating system to dark mode. The browser flips this for you the moment the OS changes — no JavaScript and no page reload needed.

The clean way to theme a page is with CSS custom properties (variables). You declare colours once on :root — --bg , --text , --primary — and every rule reads them with var(--bg) . To go dark, you re-declare the same variables with darker values inside the media query. Because the rest of your CSS never hardcodes a colour, the whole page repaints from those few lines.

Real apps also need a manual override : some people want dark even when their OS is light. Add a [data-theme] attribute on and declare the variables again under [data-theme="dark"] . Because an attribute selector beats a bare :root , the user's choice wins over the system setting. Removing the attribute hands control back to the OS. That's the full stack: system default → user override → remembered choice .

One easily-missed piece is the color-scheme property. Setting color-scheme: light dark on :root tells the browser to paint native UI — form fields, scrollbars, the default page canvas — in the matching scheme. Forget it and you'll get white inputs and a white scrollbar on an otherwise dark page.

1. Worked example: system theme + a toggle

Read every comment, then run it. With no button clicked it follows your OS. Click Light or Dark to override, and System to hand control back. This one example contains all four layers — color-scheme , the media query, the variables, and the [data-theme] override.

Why the variables repaint everything: custom properties follow the normal cascade. When the media query or [data-theme] re-declares --bg on :root , every element using var(--bg) instantly reads the new value. You never toggle a class on individual elements — you change the source of truth once.

2. 🎯 Your turn: add system dark mode

This page already has light colours. Make it follow the OS: set color-scheme , point the media query at dark , and give --bg a near-black value. Each ___ has a 👉 hint, and the expected result is in the comment at the bottom.

3. 🎯 Your turn: toggle + remember the choice

Finish the JavaScript toggle: apply the chosen theme to , save it with localStorage , and re-apply the saved value on load. Check your work against the ✅ Expected comment.

4. Mini-challenge: kill the theme flash

No blanks this time — just a comment outline in a script. Read the saved theme (or fall back to the system preference) and set data-theme before the body paints, so a dark-mode user sees no white flash on load. The steps and expected result are in the comments.

Practice quiz

Which media query detects that the user's OS is set to dark mode?

  • @media (color-scheme: dark)
  • @media (theme: dark)
  • @media (prefers-color-scheme: dark)
  • @media (dark-mode: on)

Answer: @media (prefers-color-scheme: dark). @media (prefers-color-scheme: dark) is true when the operating system is set to dark mode.

Do you need JavaScript just to follow the OS dark mode setting?

  • No — the media query handles it in pure CSS
  • Yes, always
  • Only in Safari
  • Only with a framework

Answer: No — the media query handles it in pure CSS. prefers-color-scheme works in pure CSS; JavaScript is only needed for a manual override toggle.

What does color-scheme: light dark on :root do?

  • Sets the page background
  • Enables CSS variables
  • Forces dark mode
  • Tells the browser to render native UI (controls, scrollbars) in the matching scheme

Answer: Tells the browser to render native UI (controls, scrollbars) in the matching scheme. color-scheme makes native widgets like form fields and scrollbars match the active scheme.

What is the recommended way to swap a page's colors for dark mode?

  • Edit every rule by hand
  • Re-declare the same CSS custom properties with darker values
  • Use a different stylesheet per element
  • Toggle a class on each element

Answer: Re-declare the same CSS custom properties with darker values. Declare colors as variables once, then re-declare the same variables with dark values in the media query.

Why does [data-theme="dark"] override @media (prefers-color-scheme: dark)?

  • An attribute selector is more specific than a bare :root in a media query
  • It loads later
  • Media queries are ignored
  • It uses !important

Answer: An attribute selector is more specific than a bare :root in a media query. The attribute selector has higher specificity than a plain :root, so the user's explicit choice wins.

How do you hand control back to the OS after a manual override?

  • Reload the page
  • Delete localStorage
  • Remove the data-theme attribute from <html>
  • Set color-scheme to none

Answer: Remove the data-theme attribute from <html>. Removing the data-theme attribute lets the media query (system preference) take over again.

How do you remember the user's theme choice across refreshes?

  • A CSS variable
  • localStorage.setItem('theme', value)
  • The color-scheme property
  • A media query

Answer: localStorage.setItem('theme', value). Save the choice with localStorage.setItem and re-apply it on load.

What causes the flash of the wrong theme (FOUC) on load?

  • Using CSS variables
  • Using color-scheme
  • A missing caption
  • Setting the theme after the page has already painted

Answer: Setting the theme after the page has already painted. Reading localStorage and applying the theme after first paint causes a brief flash of the default theme.

How do you prevent the theme flash on page load?

  • Avoid dark mode
  • Set data-theme in a blocking <script> in the <head> before the body renders
  • Use a deferred script at the page bottom
  • Add transition: none

Answer: Set data-theme in a blocking <script> in the <head> before the body renders. A small blocking <head> script sets data-theme before the first paint, so it's already correct.

Why is pure black (#000000) usually avoided for dark mode backgrounds?

  • It is invalid CSS
  • Browsers render it as grey anyway
  • Harsh contrast causes eye strain and leaves no room for elevation cues
  • It uses more battery

Answer: Harsh contrast causes eye strain and leaves no room for elevation cues. Pure black against white text causes halation/eye strain; a dark grey like #121212 is recommended.