One-off tip

Support checkout

  1. 1 Choose amount
  2. 2 Payment
  3. 3 Thank you

Choose amount

Pick the level of support that feels right. You can keep it simple or enter a custom amount, then continue to secure payment.

Choose a one-off amount
Building a dark/light theme toggle in Astro
Cover image: White ceramic mug filled with black coffee, viewed from above — Photo by Alex Padurariu on Unsplash
astro engineering frontend

Building a dark/light theme toggle in Astro

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

Published
14 April 2026
Read time
6 min read

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, 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 is 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, the 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. when 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.

Working on something similar?

Need help raising the bar?

I help teams improve engineering practice through hands-on delivery, pragmatic reviews, and mentoring. If you want a second pair of eyes or practical support, let's talk.

  • Engineering practice review
  • Hands-on delivery
  • Team mentoring
Get guidance

If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.

Comments

Loading comments…

Leave a comment

Free · Practical · One email per post

Get practical engineering notes

One short email when a new article goes live. Useful if you are breaking into tech, growing as an engineer, or improving engineering practice on your team.