Module 9: Real-World Usage
Theming, Dark Mode and Accessible CSS
How to build a theme system on custom properties, respect OS preferences, add a user toggle without a flash, and the CSS details that make interfaces usable for everyone.
Dark mode is a visible example of something bigger: your CSS has to adapt to the person using it. Their color preference, motion tolerance, contrast needs, input device and text size are all signals the browser exposes, and you can respond to them in CSS.
Build themes from tokens#
Never hard-code colors in components. Define what a color means, and let a theme provide the value:
:root {
color-scheme: light dark;
--bg: #ffffff;
--surface: #f3f4f6;
--text: #111827;
--muted: #4b5563;
--border: #d1d5db;
--brand: #0b5fff;
--on-brand: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b1220;
--surface: #172033;
--text: #e5e7eb;
--muted: #9ca3af;
--border: #2b3750;
--brand: #6ea0ff;
--on-brand: #0b1220;
}
}
body { background: var(--bg); color: var(--text); }
.card { background: var(--surface); border: 1px solid var(--border); }
.btn { background: var(--brand); color: var(--on-brand); }
Notice that the dark --brand is lighter. A saturated blue that works on white often looks heavy and low-contrast on a dark background. Dark themes are not just inverted colors. Pure black backgrounds with pure white text also tire the eyes, which is why dark palettes lean toward very dark blues or grays and off-white text.
color-scheme#
:root { color-scheme: light dark; }
This declaration tells the browser your page supports both. It then adjusts its own built-in UI to match: form controls, scrollbars and the default canvas background all switch automatically. Without it, a dark page can have a bright white scrollbar and white text inputs.
Letting users choose#
The OS preference is a good default, but users often want to override it per site. Use a data-theme attribute on html that takes priority:
:root { /* light tokens */ }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { /* dark tokens */ }
}
:root[data-theme="dark"] { /* the same dark tokens */ }
<button id="theme" aria-pressed="false">Dark mode</button>
const root = document.documentElement;
const btn = document.querySelector("#theme");
function apply(theme) {
root.dataset.theme = theme;
btn.setAttribute("aria-pressed", String(theme === "dark"));
localStorage.setItem("theme", theme);
}
btn.addEventListener("click", () => {
apply(root.dataset.theme === "dark" ? "light" : "dark");
});
The tokens are duplicated in two places, which is the cost of supporting both the OS setting and the manual override. Sass or a build step can generate one from the other. A newer alternative, the light-dark() function, removes some of the repetition:
:root { color-scheme: light dark; }
body { background: light-dark(#ffffff, #0b1220); color: light-dark(#111827, #e5e7eb); }
Avoiding the flash#
If JavaScript sets the saved theme after the page has rendered, users with a dark preference see a flash of light. Set the attribute in a tiny inline script in the head, before the CSS paints:
<script>
const t = localStorage.getItem("theme");
if (t) document.documentElement.dataset.theme = t;
</script>
This is one of the few places where blocking inline JavaScript is the right choice.
Contrast#
Contrast ratios must be checked in both themes. A gray that reads well on white at 4.6:1 can fall to 3:1 on a dark background. In DevTools, the color picker shows the ratio and a pass or fail indicator. Interactive elements and their borders need 3:1 against adjacent colors as well.
Focus you can see#
:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
A global :focus-visible rule gives every interactive element a visible indicator. Make sure it has enough contrast on all your background colors. Do not remove outlines. If the default does not suit your design, replace it.
Motion#
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
This is one of the rare places where !important is defensible. It overrides everything because it is a user safety preference. Better still, design the motion with reduced preferences in mind from the start, and apply animation only inside @media (prefers-reduced-motion: no-preference).
Text size and zoom#
- Use
remfor font sizes and spacing so the page follows the user's default size. - Do not set
html { font-size: ... }in pixels. - Test at 200% browser zoom. Layouts should reflow into one column with no horizontal scrolling and no clipped text.
- Avoid fixed heights on containers that hold text.
Visually hidden content#
Sometimes you need text for screen readers that sighted users do not need, such as a label for an icon-only button. display: none removes it from the accessibility tree, so you use a utility class instead:
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
<button class="icon-btn">
<svg aria-hidden="true" width="20" height="20">…</svg>
<span class="visually-hidden">Close dialog</span>
</button>
The element stays in the accessibility tree and still receives focus, but takes up no visible space.
High contrast and forced colors#
Windows High Contrast mode replaces your colors with a user-chosen palette. Backgrounds and shadows are removed, and borders are enforced. Elements that relied only on background color for their shape vanish. Check key components with:
@media (forced-colors: active) {
.btn { border: 1px solid ButtonText; }
}
In DevTools, the Rendering panel can emulate forced colors.
Print styles#
@media print {
:root { --bg: #fff; --text: #000; }
.site-header, .site-footer, .no-print { display: none; }
a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 0.8em; }
.card { break-inside: avoid; box-shadow: none; }
}
Print is the other medium nobody tests. A few lines here make receipts, recipes and documentation usable on paper.
Common mistakes#
- Hard-coded colors in components, so adding a theme means editing everything.
- Forgetting
color-scheme, leaving bright form controls in a dark page. - Simply inverting colors for dark mode.
- A flash of the wrong theme on load.
- Checking contrast in only one theme.
- Removing
outlinewithout a replacement. - Ignoring
prefers-reduced-motion. - Using
display: nonefor screen-reader-only text. - Never testing with zoom, keyboard or high contrast.
Practice#
- Convert a page with hard-coded colors into one that uses tokens, then add a dark theme through
prefers-color-scheme. - Add a toggle button that overrides the OS setting, stores the choice, and avoids the flash on reload.
- Use DevTools Rendering emulation to test dark mode, reduced motion and forced colors on your page.
- Navigate your entire page with only the keyboard. Fix every place where you lose sight of the focus.