Building a dark/light theme toggle in Astro

Building a dark/light theme toggle in Astro

CSS custom properties, localStorage persistence, and no flash of wrong theme

By Roger Rajaratnam 7 April 2026

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.

Wireframe mockup of the navbar with the theme toggle icon in the social icon group, and the page body below showing where the inline script fires before first paint

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:

src/styles/global.scss
: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:

src/components/ThemeToggle.astro
<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:

src/styles/global.scss
.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:

src/components/ThemeToggle.astro
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:

  • setTheme is the single source of truth — it updates data-theme-current on the wrapper (for trigger icon visibility), aria-pressed on each option, and localStorage and data-theme on <html>.
  • openMenu/closeMenu keep the hidden attribute and aria-expanded in sync. Using the HTML hidden attribute (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 localStorage key rather than storing "system". Only "dark" or "light" are ever written.
  • The matchMedia change 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:

src/layouts/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:

  1. Stored explicit preference ("dark" or "light")
  2. OS prefers-color-scheme: dark
  3. 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:

astro.config.mjs
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.

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