javascript
43 lines · 8 steps
Building a dark-mode toggle that respects the OS
A theme switcher that honors the system preference until the user explicitly overrides it, then remembers that choice.
Explained by
highlit
1const STORAGE_KEY = "theme-preference";
2
3function getSystemTheme() {
4 return window.matchMedia("(prefers-color-scheme: dark)").matches
5 ? "dark"
6 : "light";
7}
8
9function getStoredTheme() {
10 return localStorage.getItem(STORAGE_KEY);
11}
12
13function applyTheme(theme) {
14 document.documentElement.dataset.theme = theme;
15 const toggle = document.querySelector("[data-theme-toggle]");
16 if (toggle) {
17 toggle.setAttribute("aria-pressed", String(theme === "dark"));
18 }
19}
20
21function resolveTheme() {
22 return getStoredTheme() ?? getSystemTheme();
23}
24
25function initThemeToggle() {
26 applyTheme(resolveTheme());
27
28 const systemQuery = window.matchMedia("(prefers-color-scheme: dark)");
29 systemQuery.addEventListener("change", (event) => {
30 if (getStoredTheme() === null) {
31 applyTheme(event.matches ? "dark" : "light");
32 }
33 });
34
35 const toggle = document.querySelector("[data-theme-toggle]");
36 toggle?.addEventListener("click", () => {
37 const next = resolveTheme() === "dark" ? "light" : "dark";
38 localStorage.setItem(STORAGE_KEY, next);
39 applyTheme(next);
40 });
41}
42
43document.addEventListener("DOMContentLoaded", initThemeToggle);
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Fall back to the OS preference only until the user makes an explicit choice, then persist it.
- 2Nullish coalescing cleanly expresses 'stored value wins, otherwise derive a default'.
- 3Keeping DOM state and ARIA attributes in one apply function keeps visuals and accessibility in sync.
Related explainers
javascript
'use client'; import { useRouter } from 'next/navigation'; import Link from 'next/link';
Archiving with router.refresh in Next.js
client-component
data-mutation
transitions
Intermediate
7 steps
typescript
import { Directive, EventEmitter, HostListener, Input, Output } from '@angular/core'; interface Shortcut { key: string;
A keyboard shortcut directive in Angular
directives
event-handling
keyboard-shortcuts
Intermediate
9 steps
javascript
import { useState, useRef, useCallback } from "react"; export function MultiSelect({ options, value, onChange, placeholder = "Select…" }) { const [open, setOpen] = useState(false);
Building a keyboard-accessible MultiSelect in React
controlled-component
accessibility
keyboard-navigation
Intermediate
10 steps
javascript
async function uploadInBatches(records, uploadFn, { batchSize = 100, concurrency = 3 } = {}) { const batches = []; for (let i = 0; i < records.length; i += batchSize) { batches.push(records.slice(i, i + batchSize));
Uploading records with bounded concurrency
concurrency
worker-pool
async-await
Advanced
8 steps
javascript
function formatPhoneNumber(value) { const digits = value.replace(/\D/g, '').slice(0, 10); const parts = [];
Building a live phone number input mask
input-masking
regex
dom-events
Intermediate
7 steps
javascript
import { NextResponse } from 'next/server'; const locales = ['en', 'fr', 'de', 'es']; const defaultLocale = 'en';
Locale routing with Next.js middleware
middleware
i18n
content-negotiation
Intermediate
10 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/building-a-dark-mode-toggle-that-respects-the-os-explained-javascript-89e7/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.