Building a dark/light theme toggle in Astro
CSS custom properties, localStorage persistence, and no flash of wrong theme
A dark/light toggle is one of those features that sounds trivial and isn’t. Get it wrong and you ship the flash of wrong theme — the page loads light, then flickers dark if that was the user’s stored preference. Or you ship a toggle that forgets its state on every page load.
This blog’s implementation avoids all of those. There’s also a third option that
most implementations skip: a “System” mode that tracks the OS preference in
real time, without touching localStorage.
The colour system
All colours are defined as CSS custom properties on :root and overridden under
[data-theme='dark']. There’s no separate dark-mode stylesheet, no class-swapping
on individual elements — just two variable sets and one attribute:
:root { --color-pink: #e8006a; --color-ink: #0f0f0f; --color-paper: #ffffff; --color-muted: #6b6b6b; --color-surface: #ffffff; --color-border: rgba(0, 0, 0, 0.1);}
[data-theme='dark'] { --color-ink: #f0f0f0; --color-paper: #111111; --color-muted: #999999; --color-surface: #1c1c1c; --color-border: rgba(255, 255, 255, 0.1);}Switching between themes is a single setAttribute call on document.documentElement.
Every element on the page that uses a custom property updates instantly.
The pink accent (--color-pink) doesn’t change between themes — it’s a
fixed identity colour, not a semantic one.
The toggle component
The toggle lives in the navbar’s social icon group inside a dedicated ThemeToggle.astro
component. It’s a single icon button that opens a small dropdown menu with System,
Light, and Dark options:
<div class="theme-toggle"> <button class="theme-toggle__trigger social-icon" aria-label="Theme preference" aria-expanded="false" aria-haspopup="true" > <span class="theme-toggle__trigger-icon theme-toggle__trigger-icon--system"> <!-- half-stroke circle icon --> </span> <span class="theme-toggle__trigger-icon theme-toggle__trigger-icon--light"> <!-- sun icon --> </span> <span class="theme-toggle__trigger-icon theme-toggle__trigger-icon--dark"> <!-- moon icon --> </span> </button>
<div class="theme-toggle__dropdown" role="menu" aria-label="Theme preference" hidden> <button class="theme-toggle__option" data-theme-select="system" role="menuitem" aria-pressed="true"> <!-- half-stroke circle --> System </button> <button class="theme-toggle__option" data-theme-select="light" role="menuitem" aria-pressed="false"> <!-- sun --> Light </button> <button class="theme-toggle__option" data-theme-select="dark" role="menuitem" aria-pressed="false"> <!-- moon --> Dark </button> </div></div>The trigger carries aria-haspopup="true" and aria-expanded (toggled by the
script). The dropdown starts hidden; the script removes that attribute to reveal it.
Three icon spans live inside the trigger — only one visible at a time. CSS targets
data-theme-current on the wrapper div (set by the script) to show the right icon:
.theme-toggle__trigger-icon { display: none; }
.theme-toggle[data-theme-current="system"] .theme-toggle__trigger-icon--system,.theme-toggle[data-theme-current="light"] .theme-toggle__trigger-icon--light,.theme-toggle[data-theme-current="dark"] .theme-toggle__trigger-icon--dark { display: flex;}This makes the trigger reflect the user’s chosen preference, not the resolved OS theme. If someone selects System and their OS is dark, the trigger shows the half-stroke circle — not the moon.
The script
The toggle script handles two concerns: dropdown open/close state, and theme
selection. Both live inside the <script> block in ThemeToggle.astro. Astro
bundles component scripts automatically:
const wrapper = document.querySelector<HTMLElement>(".theme-toggle")!;const trigger = wrapper.querySelector<HTMLButtonElement>(".theme-toggle__trigger")!;const dropdown = wrapper.querySelector<HTMLElement>(".theme-toggle__dropdown")!;const options = wrapper.querySelectorAll<HTMLButtonElement>("[data-theme-select]");const html = document.documentElement;
function setTheme(mode: string) { wrapper.dataset.themeCurrent = mode; options.forEach((btn) => btn.setAttribute("aria-pressed", btn.dataset.themeSelect === mode ? "true" : "false"), ); if (mode === "system") { localStorage.removeItem("theme"); html.setAttribute( "data-theme", window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light", ); } else { localStorage.setItem("theme", mode); html.setAttribute("data-theme", mode); }}
function openMenu() { dropdown.removeAttribute("hidden"); trigger.setAttribute("aria-expanded", "true");}
function closeMenu() { dropdown.setAttribute("hidden", ""); trigger.setAttribute("aria-expanded", "false");}
trigger.addEventListener("click", () => { if (dropdown.hasAttribute("hidden")) openMenu(); else closeMenu();});
document.addEventListener("click", (e) => { if (!wrapper.contains(e.target as Node)) closeMenu();});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenu();});
options.forEach((btn) => btn.addEventListener("click", () => { setTheme(btn.dataset.themeSelect!); closeMenu(); }),);
const stored = localStorage.getItem("theme");setTheme(stored === "dark" || stored === "light" ? stored : "system");
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => { if (!localStorage.getItem("theme")) { html.setAttribute("data-theme", e.matches ? "dark" : "light"); }});A few things worth noting:
setThemeis the single source of truth — it updatesdata-theme-currenton the wrapper (for trigger icon visibility),aria-pressedon each option, andlocalStorageanddata-themeon<html>.openMenu/closeMenukeep thehiddenattribute andaria-expandedin sync. Using the HTMLhiddenattribute (rather than a CSS class) means the closed state works even before styles load.- Clicking outside the wrapper or pressing Escape closes the menu — standard dropdown behaviour users expect.
- System mode removes the
localStoragekey rather than storing"system". Only"dark"or"light"are ever written. - The
matchMediachange listener fires when the OS theme switches while the page is open. It only acts when no explicit preference is stored — i.e. the user is in System mode.
Preventing the flash of wrong theme
If you read localStorage in a script that loads after the page renders, you’ll
see the default theme briefly before the script applies the stored preference. The
fix is to run the theme-reading code synchronously in <head>, before the body
is parsed. In BaseLayout.astro:
<html lang="en" data-theme="light"> <head> <!-- ... meta, links ... --> <script is:inline> const stored = localStorage.getItem("theme"); if (stored === "dark") { document.documentElement.setAttribute("data-theme", "dark"); } else if (stored === "light") { document.documentElement.setAttribute("data-theme", "light"); } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) { document.documentElement.setAttribute("data-theme", "dark"); } </script>The priority order is:
- Stored explicit preference (
"dark"or"light") - OS
prefers-color-scheme: dark - Fallback — the
data-theme="light"already on the<html>tag
The is:inline directive tells Astro not to bundle or defer this script — it
stays as a literal inline <script> tag and runs immediately, before the browser
paints anything.
Expressive Code alignment
Expressive Code — the syntax highlighting library — needs to know to follow the
data-theme attribute rather than the OS prefers-color-scheme media query.
Without this, code blocks would follow the system preference even when the user
has picked an explicit theme — they’d be out of sync:
expressiveCode({ useDarkModeMediaQuery: false, themeCssSelector: (theme) => theme.type === "dark" ? '[data-theme="dark"]' : ':root:not([data-theme="dark"])',})useDarkModeMediaQuery: false disables the default @media (prefers-color-scheme)
approach. themeCssSelector maps each Expressive Code theme variant to a CSS
selector that matches the data-theme attribute instead.
Support this blog
If you found this useful, a small tip keeps the writing going.
Free · No spam · Unsubscribe any time
Get new posts in your inbox
When the next article drops, I'll send a short note — a link and a summary, nothing else. One email per post.
Did you find this useful?
Comments
Loading comments…
Leave a comment