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

Walkthrough

Space play step click any line
Three takeaways
  1. 1Fall back to the OS preference only until the user makes an explicit choice, then persist it.
  2. 2Nullish coalescing cleanly expresses 'stored value wins, otherwise derive a default'.
  3. 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.

Building a dark-mode toggle that respects the OS — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code