More Buttons Snippets
Color Mode Toggle — Light/Dark/System Switch UI
Color Mode Toggle · Buttons · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Color Mode Toggle — Three-Way Light / Dark / System Theme Switch

A two-state dark-mode toggle forces a choice the user shouldn't have to make: it ignores that their device already has a light/dark preference that changes with time of day. The modern pattern is a three-way switch — Light, Dark, and System — where System defers to the OS setting and updates live when it changes. This snippet builds that segmented theme control in plain HTML, CSS, and vanilla JavaScript, with a sliding thumb, CSS-variable theming, and correct System behavior.
Why "System" is the important option
"System" (sometimes "Auto") is what most users actually want as a default: it follows the device's prefers-color-scheme, so the site is light during the day and dark at night without anyone touching a setting. The key behavior is that System isn't a snapshot — it's a *live* binding. This snippet listens to the matchMedia('(prefers-color-scheme: dark)') change event and re-applies the theme whenever the OS flips, but only while the user is in System mode. Pick Light or Dark explicitly and you override the OS; pick System and you hand control back to it. Getting this live-follow behavior right is what separates a real three-way toggle from one that just has a third button.
Theming with CSS custom properties
The whole component themes through CSS variables. :root-equivalent tokens on the container (--bg, --text, --border, --accent) define the light theme, and a [data-theme="dark"] selector overrides them for dark. Every element references the variables, so switching themes is a single attribute change on one element — no per-element class toggling. A transition on the color properties makes the switch a smooth crossfade rather than an abrupt flip. This is the standard, scalable way to theme a site: define tokens once, flip one attribute.
The sliding thumb
The three options sit in a pill, and a thumb slides beneath the active one using transform: translateX() (the index times the option width), with a slight spring easing. Animating transform keeps the motion GPU-cheap and smooth, and the active icon tints to the accent color so the selection is clear from both the thumb position and the color. A small status chip echoes the current mode and, in System mode, the resolved theme ("System · dark") so the user can see what System currently maps to.
Scoped demo vs. real app
This demo applies the theme to its own container (.cmt-demo) so the surrounding page stays neutral — but in a real app you'd set data-theme on <html> and persist the choice to localStorage, then read it back on load (ideally in a tiny inline script in the <head>, before first paint, to avoid a flash of the wrong theme). The code comments mark exactly where those two lines go. The resolution logic — mode → resolved theme — is identical whether you scope it to a container or the whole document.
Accessible and keyboard-ready
The switch is a role="radiogroup" of icon buttons, each with an aria-label (Light / System / Dark), so it's operable and announced correctly. Building it from real buttons means keyboard focus and activation work without custom handling, and the visual selection never relies on color alone — the thumb position is a second, redundant indicator.
Step by step
How to Use
- 1Paste HTML, CSS, and JSA themed preview panel renders with a Light / System / Dark switch; the thumb starts on System.
- 2Pick a modeClick Light or Dark to set the theme explicitly; the panel crossfades and the thumb slides to your choice.
- 3Use SystemChoose System to follow your device's setting — the status chip shows what it currently resolves to ("System · dark").
- 4Change your OS themeWhile in System mode, flip your operating system's appearance — the panel updates live without a click.
- 5Apply to your whole siteSet data-theme on <html> instead of the demo container and define your token variables on :root and [data-theme="dark"].
- 6Persist the choiceSave the mode to localStorage on change and read it back in a head inline script before first paint to avoid a theme flash.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Define your token variables on :root (light) and a html[data-theme="dark"] selector (dark overrides), set data-theme on document.documentElement instead of the demo container, and have every component reference the variables. Switching then re-themes the entire page from one attribute change.
Read the saved mode from localStorage and set data-theme on <html> in a tiny synchronous inline <script> in the <head>, before any CSS or content paints — this applies the correct theme before first render. Doing it in a deferred script or after hydration causes a visible flash (FOUC) of the default theme first.
"Dark" is an explicit override that stays dark regardless of the OS. "System" defers to the device's prefers-color-scheme and changes live when the OS does — so a System user on a device that switches to dark at sunset gets dark automatically, while a Dark user is always dark. Persisting the mode (light/dark/system), not just the resolved theme, preserves this distinction.
On every change, localStorage.setItem('theme', mode) storing the mode value ('light' | 'dark' | 'system'). On load, read it back, default to 'system' if absent, and apply. Store the mode rather than the resolved theme so a System user keeps following the OS rather than being pinned to whatever it resolved to last time.
In React, hold the mode in useState (initialized from localStorage), apply data-theme in a useEffect, and subscribe to the matchMedia change event for System; in Vue, use ref() with onMounted/watch; in Angular, use a service with a BehaviorSubject and Renderer2. The resolution logic (mode → theme) and the matchMedia listener port unchanged.