CSS Dark and Light Mode
A theme switcher built from a checkbox, CSS custom properties and no JavaScript at all.
CSS dark and light mode comes down to two ideas. A checkbox or a radio holds the state, and custom properties hold the colors, so flipping the control redefines a handful of tokens and every rule that reads them repaints. The switch below is a checkbox and a label, and the panel underneath responds through the general sibling combinator.
That covers a manual toggle. A real theme has three more pieces to get right: prefers-color-scheme for the reader who has already told their operating system what they want, a third state for following that setting rather than overriding it, and the color-scheme property for the parts of the page the browser paints rather than you. All three appear in the versions below.
A toggle that repaints a panel
HTML
<input class="theme-check" type="checkbox" id="theme1">
<label class="theme-toggle" for="theme1">
<span class="theme-track"></span>
Dark mode
</label>
<div class="theme-preview">
This area reacts to the theme toggle: background, text, and border all change.
</div>
CSS
.theme-check { display: none; }
.theme-toggle {
display: inline-flex;
align-items: center;
gap: .75rem;
font-size: .95rem;
cursor: pointer;
user-select: none;
}
.theme-track {
width: 52px;
height: 28px;
background: #ff9040;
border-radius: 999px;
position: relative;
transition: background .3s;
}
/* Sun/moon detail sitting behind the knob */
.theme-track::before {
content: "☀️";
position: absolute;
top: 50%;
left: 5px;
transform: translateY(-50%);
font-size: .9rem;
transition: opacity .3s;
}
.theme-track::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
background: #141415;
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, .2);
transition: transform .3s;
}
.theme-check:checked + .theme-toggle .theme-track {
background: #1c1c1e;
}
.theme-check:checked + .theme-toggle .theme-track::before {
content: "🌙";
}
.theme-check:checked + .theme-toggle .theme-track::after {
transform: translateX(24px);
}
.theme-preview {
margin-top: 1.25rem;
padding: 1.25rem;
border: 2px solid #ff9040;
border-radius: 10px;
background: rgba(255, 144, 64, 0.12);
color: #f0f0f0;
font-size: .9rem;
transition: background .3s, color .3s, border-color .3s;
}
.theme-check:checked ~ .theme-preview {
background: #141415;
border-color: #b8ff57;
}
Other ways to build it
Light, dark, or follow the system
Three radios in one group, with the third deferring to prefers-color-scheme rather than setting anything itself. The panel reads five custom properties and never mentions a color directly, so each state is a short block of token redefinitions. The radios are clipped rather than hidden with display: none, which keeps them focusable and announced, and the focus ring is drawn on the label.
Every color in this panel comes from a custom property. Switching the radio redefines five tokens and nothing else.
SavedHTML
<div class="dlm-demo">
<input type="radio" name="dlm-theme" id="light" class="dlm-radio dlm-r-light" checked>
<input type="radio" name="dlm-theme" id="dark" class="dlm-radio dlm-r-dark">
<input type="radio" name="dlm-theme" id="system" class="dlm-radio dlm-r-sys">
<div class="dlm-controls">
<label for="light" class="dlm-lab dlm-lab-light">Light</label>
<label for="dark" class="dlm-lab dlm-lab-dark">Dark</label>
<label for="system" class="dlm-lab dlm-lab-sys">System</label>
</div>
<div class="dlm-panel">
<b>Notification settings</b>
<p>Every color in this panel comes from a custom property. Switching the radio redefines five tokens and nothing else.</p>
<span class="dlm-chip">Saved</span>
</div>
</div>
CSS
/* clipped, not display:none, so it stays focusable and announced */
.dlm-radio {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
/* the panel names no colors at all, only tokens */
.dlm-panel {
background: var(--dlm-bg);
color: var(--dlm-fg);
border: 1px solid var(--dlm-line);
color-scheme: var(--dlm-scheme);
}
.dlm-r-light:checked ~ .dlm-panel,
.dlm-r-sys:checked ~ .dlm-panel {
--dlm-bg: #ffffff;
--dlm-fg: #1a1a1a;
--dlm-muted: #666660;
--dlm-line: #d0ccc0;
--dlm-accent: #097900;
--dlm-scheme: light;
}
.dlm-r-dark:checked ~ .dlm-panel {
--dlm-bg: #141415;
--dlm-fg: #f0f0f0;
--dlm-muted: #88888f;
--dlm-line: #2a2a2d;
--dlm-accent: #b8ff57;
--dlm-scheme: dark;
}
/* the system option sets nothing of its own; the media query does */
@media (prefers-color-scheme: dark) {
.dlm-r-sys:checked ~ .dlm-panel {
--dlm-bg: #141415;
--dlm-fg: #f0f0f0;
--dlm-muted: #88888f;
--dlm-line: #2a2a2d;
--dlm-accent: #b8ff57;
--dlm-scheme: dark;
}
}
/* the input is invisible, so the ring goes on the matching label. Each
pair is written out, because ~ would otherwise ring all three at once */
.dlm-r-light:focus-visible ~ .dlm-controls .dlm-lab-light,
.dlm-r-dark:focus-visible ~ .dlm-controls .dlm-lab-dark,
.dlm-r-sys:focus-visible ~ .dlm-controls .dlm-lab-sys {
outline: 2px solid #b8ff57;
outline-offset: 2px;
}
color-scheme, for the parts you do not paint
These two panels differ by one declaration. Nothing styles the controls inside them, so the browser draws each one in the palette that color-scheme names: the input, the select, the checkbox and the scrollbar all follow. No custom property can reach any of these, which is why a fully themed dark page still arrives with a bright white scrollbar unless this property is set.
A scroll container, so the browser draws its own scrollbar in the scheme this panel declares.
Nothing in the stylesheet touches the track or the thumb.
The caret in the field above follows the same setting.
A scroll container, so the browser draws its own scrollbar in the scheme this panel declares.
Nothing in the stylesheet touches the track or the thumb.
The caret in the field above follows the same setting.
HTML
<div class="dlm-cs-row">
<div class="dlm-cs dlm-cs-light">
<span class="dlm-cs-tag">color-scheme: light</span>
<input type="text" value="Ada Lovelace" aria-label="Name, light scheme">
<select aria-label="Digest frequency, light scheme"><option>Weekly digest</option><option>Daily digest</option></select>
<label class="dlm-cs-check"><input type="checkbox" checked> Email me</label>
<div class="dlm-cs-scroll"><p>A scroll container, so the browser draws its own scrollbar in the scheme this panel declares.</p><p>Nothing in the stylesheet touches the track or the thumb.</p><p>The caret in the field above follows the same setting.</p></div>
</div>
<div class="dlm-cs dlm-cs-dark">
<span class="dlm-cs-tag">color-scheme: dark</span>
<input type="text" value="Ada Lovelace" aria-label="Name, dark scheme">
<select aria-label="Digest frequency, dark scheme"><option>Weekly digest</option><option>Daily digest</option></select>
<label class="dlm-cs-check"><input type="checkbox" checked> Email me</label>
<div class="dlm-cs-scroll"><p>A scroll container, so the browser draws its own scrollbar in the scheme this panel declares.</p><p>Nothing in the stylesheet touches the track or the thumb.</p><p>The caret in the field above follows the same setting.</p></div>
</div>
</div>
CSS
/* one declaration each; nothing below styles the controls at all */
.dlm-cs-light { color-scheme: light; }
.dlm-cs-dark { color-scheme: dark; }
/* on a real site it goes at the root, next to the tokens */
:root {
color-scheme: dark;
}
/* and follows the toggle, if there is one */
body:has(#theme-toggle:checked) {
color-scheme: light;
}
How it works
A hidden checkbox holds the theme state. The general sibling combinator (~) lets elements further down the DOM respond to its :checked state. For a page wide theme, put the control near the top of the document and override custom properties on a wrapper, so every rule that reads those tokens repaints at once instead of being written twice.
The selector is doing the state management. ~ matches later siblings of the checked input, which is why the input has to come before everything it styles and can never reach an ancestor. That constraint is what makes the classic version awkward on a real page, and it is why this site's own toggle sits immediately after the opening <body> tag. With :has() the constraint disappears: body:has(#theme-toggle:checked) styles the body from a descendant's state, so the control can live in the header where it belongs.
Custom properties are what make the swap cheap. A theme is not a second stylesheet, it is a redefinition of maybe fifteen tokens on one selector. Every rule downstream reads var(--surface) or var(--text) and re-resolves the moment the definition changes, because custom properties inherit and cascade like any other declaration. Write colors literally in components instead and a theme swap means editing every rule twice, and missing some.
prefers-color-scheme is the reader's existing answer to the question, taken from their operating system, and it should be the default rather than an afterthought. The pattern that respects it is three way rather than two: an explicit light, an explicit dark, and a third option that defers to the media query. A two state toggle silently overrides a system preference the first time the page loads, which is the wrong default for someone who set that preference for a reason.
One thing custom properties cannot reach is anything the browser paints itself. Scrollbars, form controls, the canvas behind the page and the text caret are all drawn by the user agent, and they do not read your variables. The color-scheme property is how you tell the browser which palette to use for those. Set color-scheme: dark and a native checkbox, a select and a scrollbar all come back dark. Leave it out and a carefully themed dark page still has a bright white scrollbar down its right edge.
What CSS cannot do is remember. There is no way to persist a checkbox state across a page load, and no selector that reads a stored preference, so a pure CSS theme resets on every navigation. That is fine for a demo and wrong for a product, where one line of script writing an attribute on <html> is the usual fix. Colors that must not be themed at all are a separate problem, covered in CSS difference blend mode, where the arithmetic depends on real channel values. Components sometimes need a small theme aware tweak of their own, as the shimmer direction does in CSS skeleton loader.
CSS properties used
custom properties- The tokens a theme redefines. Because they inherit and cascade, changing them on one ancestor repaints everything downstream with no other rule touched.
:checked- Matches a checked checkbox or radio. Combined with a combinator it is the whole state machine, with no script and no class names to toggle.
~- The general sibling combinator. It reaches later siblings of the control, which is why the input has to sit before the content it themes.
:has()- Lets an ancestor respond to a descendant's state, so the control can be anywhere in the document rather than pinned to the top.
prefers-color-scheme- Reports the reader's operating system preference. It belongs in the default, with any explicit toggle layered over it.
color-scheme- Tells the browser which palette to paint its own widgets in: scrollbars, form controls, the page canvas and the caret. Custom properties cannot reach any of those.
transition- Softens the swap on the properties that carry it. Applying it to everything is what causes a page to fade in oddly on first paint.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
custom properties | 49 | 31 | 10 | 16 |
prefers-color-scheme | 76 | 67 | 12.1 | 79 |
:has() | 105 | 121 | 15.4 | 105 |
transition | 4 | 5 | 5.1 | 12 |
:focus-visible | 86 | 4 | 15.4 | 86 |
Figures come from Can I Use for CSS Variables, the prefers-color-scheme media query, the :has() relational pseudo-class, CSS Transitions and :focus-visible. Can I Use has no separate entry for the color-scheme property, so it is not given version numbers here. It degrades harmlessly: a browser that ignores it paints its widgets in its default palette, which is what would have happened anyway. :has() is the newest piece and the only one worth a fallback, and the fallback is the older layout with the control placed before the content it themes.
Accessibility notes
Do not hide the control with display: none. That removes it from the tab order and from the accessibility tree, so it cannot be reached or announced, and the theme becomes mouse only. The demo at the top of this page does exactly that and is the wrong pattern to copy. Clip the input instead, with position: absolute and a one pixel box, so it stays focusable, and put the focus ring on the label since the input itself is invisible.
A label reading "Dark mode" next to a switch does not say whether dark mode is currently on. Use a real <input type="checkbox"> so the checked state is announced, or role="switch" with aria-checked if you build it from something else. The three way radio version below is clearer again, because a radio group announces which of the three options is selected.
Check contrast in both themes, not one. A muted gray that passes at 4.5 to 1 on a dark surface will usually fail on a light one, since the two backgrounds are not equally far from mid gray. Themes also need color-scheme for the widgets the browser paints, otherwise a dark page hands the reader a bright white scrollbar and light form controls that no stylesheet in the page can reach.
What you can build with it
- Site wide theme switch. The header control on this page, holding its state in a checkbox near the top of the document and reaching the body with
:has(). - Respecting the system setting. A default that follows
prefers-color-schemewith no control at all, which is the right starting point for most sites. - Three way preference. Light, dark, and follow the system, which is what operating systems themselves offer and what readers expect.
- Per component themes. A dark code panel inside a light page, done by redefining tokens on that one wrapper rather than on the root.
- Print and high contrast. The same token redefinition applied under a print media query, so the page comes out on paper without a dark background.
Mistakes worth avoiding
- Hiding the checkbox with
display: none. The toggle stops being reachable by keyboard and stops being announced, which turns a theme switch into a mouse only control. - Putting the control after the content it themes.
~only matches later siblings, so nothing happens and the selector looks correct while doing nothing. - Writing colors literally in components. The theme swap misses them, and the page ends up half dark with a few stubbornly light panels.
- Forgetting
color-scheme. Scrollbars, native form controls and the page canvas keep the browser's default light palette on top of a fully dark design. - Putting a transition on every property of every element. The first paint animates from the browser default styles, so the page visibly fades into place on load.
Frequently asked questions
How do I make dark mode with CSS only?
Should dark mode follow the system setting?
prefers-color-scheme is what the reader already chose, so make it the starting value and treat an in page toggle as an override. The clearest version offers three options, light, dark and follow the system, which is what the operating systems themselves offer.What is the color-scheme property for?
color-scheme: dark keeps a white scrollbar and light form widgets.Why does my theme reset when I reload the page?
<html> element.Can I put the theme toggle anywhere on the page?
:has(), yes. body:has(#theme-toggle:checked) lets an ancestor respond to a descendant's state, so the control can sit in the header. Without it, the sibling combinator only reaches forward, so the input has to come before everything it themes.