A manual theme switcher is the control that shows whether the page is using its light or dark palette. The visual state needs to match the saved theme, stay readable on both backgrounds, and remain obvious when a keyboard user tabs to the control.
Application code should already change data-theme on the page root and update aria-checked on the switch button. Custom properties hold the color tokens, so the page background, text, switch track, switch thumb, and focus outline all follow the active theme.
Use color-scheme with the selected theme so browser-drawn controls inside the page match the same light or dark context. Replace the colors with project tokens only after checking the toggle text, track, thumb, and focus outline against the final backgrounds.
<button class="theme-toggle" type="button" role="switch" aria-checked="false"> <span class="theme-toggle__track" aria-hidden="true"> <span class="theme-toggle__thumb"></span> </span> <span class="theme-toggle__text">Light mode</span> </button>
The theme script should update aria-checked and the visible label whenever it changes the page theme.
:root, html[data-theme="light"] { color-scheme: light; --page-bg: #f8fafc; --page-text: #0f172a; --panel-bg: #ffffff; --panel-border: #cbd5e1; --toggle-track: #cbd5e1; --toggle-track-active: #2563eb; --toggle-thumb: #ffffff; --toggle-focus: #f59e0b; }
color-scheme lets browser UI such as form controls and scrollbars use the declared light context where supported.
html[data-theme="dark"] { color-scheme: dark; --page-bg: #0f172a; --page-text: #e5edf7; --panel-bg: #111827; --panel-border: #334155; --toggle-track: #475569; --toggle-track-active: #60a5fa; --toggle-thumb: #082f49; --toggle-focus: #facc15; }
Keep the custom property names identical between themes so component rules do not need separate light and dark selectors.
body { margin: 0; color: var(--page-text); background: var(--page-bg); } .theme-toggle { display: inline-flex; align-items: center; gap: 0.75rem; border: 0; border-radius: 999px; padding: 0.35rem 0.5rem; color: var(--page-text); background: transparent; font: inherit; font-weight: 700; cursor: pointer; } .theme-toggle__track { display: inline-flex; align-items: center; width: 3.25rem; height: 1.75rem; padding: 0.25rem; border: 1px solid var(--panel-border); border-radius: 999px; background: var(--toggle-track); transition: background-color 160ms ease, border-color 160ms ease; } .theme-toggle__thumb { width: 1.2rem; height: 1.2rem; border-radius: 999px; background: var(--toggle-thumb); box-shadow: 0 1px 4px rgb(15 23 42 / 0.28); transition: transform 160ms ease; }
.theme-toggle[aria-checked="true"] .theme-toggle__track { border-color: var(--toggle-track-active); background: var(--toggle-track-active); } .theme-toggle[aria-checked="true"] .theme-toggle__thumb { transform: translateX(1.5rem); }
The transform distance equals the available track space after thumb width and padding are removed.
.theme-toggle:focus-visible { outline: 3px solid var(--toggle-focus); outline-offset: 4px; }
Do not remove the browser outline unless the replacement focus ring is visible on both light and dark backgrounds.
Related: How to style keyboard focus with CSS
@media (prefers-reduced-motion: reduce) { .theme-toggle__track, .theme-toggle__thumb { transition: none; } }
light color-scheme: light light aria-checked: false light track background: rgb(203, 213, 225) light text contrast: 17.06:1 dark color-scheme: dark dark aria-checked: true dark track background: rgb(96, 165, 250) dark thumb transform: matrix(1, 0, 0, 1, 24, 0) dark text contrast: 15.12:1 keyboard focus outline: 3px solid rgb(250, 204, 21)
The exact RGB values will change with project colors. The selected state should still move the thumb, update the track, keep readable text, and show a visible focus outline.