Type something to search...
How to add dark mode to an Astro site?

How to add dark mode to an Astro site?

To add dark mode to an Astro site, define your colours as CSS custom properties, give them dark values inside a prefers-color-scheme: dark media query, and use those variables everywhere. That alone makes your site follow the visitor's operating system. To add a manual toggle, store the choice in localStorage, set it as a data-theme attribute on the <html> element, and apply it with a tiny <script is:inline> in the <head> so the page never flashes the wrong theme. If you use <ClientRouter />, one extra event listener carries the theme across page navigations.

Dark mode is a good example of where Astro's defaults help: there's no framework runtime to wait for, so an inline script can set the theme before the first paint. In this article you'll build a complete setup step by step: colour tokens, system preference support, a persistent toggle, protection against the flash of incorrect theme, ClientRouter support, and a Tailwind variant. Every step was tested on Astro 7.4.2 in headless Chrome, checking the rendered background colour after each action.

Step 1: Define Colour Tokens

Start with a global stylesheet that defines every colour you use as a custom property:

/* src/styles/global.css */
:root {
  color-scheme: light dark;
  --color-bg: #ffffff;
  --color-text: #1f2328;
  --color-muted: #59636e;
  --color-accent: #0969da;
  --color-border: #d1d9e0;
}

body {
  margin: 0;
  background: var(--color-bg);
  color: var(--color-text);
  font-family: system-ui, sans-serif;
}

a {
  color: var(--color-accent);
}

Import it in your base layout so every page gets it:

---
// src/layouts/BaseLayout.astro
import "../styles/global.css";
---

The rule that makes the rest of this work: components never use raw colour values, only the variables. A scoped style in a card should say border: 1px solid var(--color-border), not #d1d9e0. If this pattern is new to you, the post on global styles in Astro covers it in more depth.

color-scheme: light dark tells the browser that the page supports both schemes, so built-in UI such as scrollbars, form controls and the default canvas colour adapt too.

Step 2: Follow the System Preference

Add dark values for the same variables inside a media query:

/* src/styles/global.css */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-bg: #0d1117;
    --color-text: #e6edf3;
    --color-muted: #9198a1;
    --color-accent: #4493f8;
    --color-border: #3d444d;
  }
}

At this point the site already has dark mode. Visitors with a dark OS theme get the dark palette and everyone else gets light, with no JavaScript at all. The :not([data-theme="light"]) part is there for the next step: it lets a visitor who chose light keep it even if their system is dark.

Step 3: Add Explicit Theme Overrides

A toggle needs a way to force a theme regardless of the system setting. Use a data-theme attribute on <html>:

/* src/styles/global.css */
:root[data-theme="dark"] {
  --color-bg: #0d1117;
  --color-text: #e6edf3;
  --color-muted: #9198a1;
  --color-accent: #4493f8;
  --color-border: #3d444d;
  color-scheme: dark;
}

:root[data-theme="light"] {
  color-scheme: light;
}

The three states now behave like this:

data-theme on htmlSystem lightSystem dark
Not setLightDark
"light"LightLight
"dark"DarkDark

The dark values appear twice, once in the media query and once in the attribute rule. You can avoid that duplication with the CSS light-dark() function, which picks a value based on the element's color-scheme:

:root {
  color-scheme: light dark;
  --color-bg: light-dark(#ffffff, #0d1117);
  --color-text: light-dark(#1f2328, #e6edf3);
}

:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }

light-dark() is supported in all current major browsers, but check your audience's browsers before relying on it. The rest of this article uses the media query version, which works everywhere.

Step 4: Prevent the Flash of Incorrect Theme

If you set the saved theme in a normal script, the browser paints the page first, then your script runs, and visitors who chose dark see a white flash on every page load. The fix is an inline script in the <head> that runs before the body is rendered:

---
// src/components/ThemeScript.astro
---
<script is:inline>
  (() => {
    const stored = localStorage.getItem("theme");
    if (stored === "light" || stored === "dark") {
      document.documentElement.dataset.theme = stored;
    }
  })();
</script>

Two details matter:

  • is:inline is required: Astro normally bundles <script> tags as deferred modules, which run after the document is parsed. That's too late. is:inline writes the script directly into the HTML, so it runs as soon as the parser reaches it.
  • Place it early in the <head>: in the built HTML, the script appeared before the page's <style> tag, so the attribute is set before any styles are applied.

If nothing is stored, the script does nothing and the media query from step 2 decides. That keeps the system preference as the default until someone actually uses the toggle.

Add the component to your layout's head:

---
// src/layouts/BaseLayout.astro
import "../styles/global.css";
import ThemeScript from "../components/ThemeScript.astro";
import ThemeToggle from "../components/ThemeToggle.astro";

const { title } = Astro.props;
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{title}</title>
    <ThemeScript />
  </head>
  <body>
    <header>
      <ThemeToggle />
    </header>
    <slot />
  </body>
</html>

Step 5: Build the Toggle Button

The toggle itself doesn't need to run before paint, so it can use a normal bundled script with TypeScript:

---
// src/components/ThemeToggle.astro
---
<button type="button" class="theme-toggle" aria-label="Toggle dark mode">
  <span class="icon-light" aria-hidden="true">☀</span>
  <span class="icon-dark" aria-hidden="true">☾</span>
</button>

<script>
  function currentTheme(): "light" | "dark" {
    const set = document.documentElement.dataset.theme;
    if (set === "light" || set === "dark") return set;
    return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
  }

  function setupToggle() {
    const button = document.querySelector<HTMLButtonElement>(".theme-toggle");
    if (!button) return;

    button.setAttribute("aria-pressed", String(currentTheme() === "dark"));

    button.addEventListener("click", () => {
      const next = currentTheme() === "dark" ? "light" : "dark";
      document.documentElement.dataset.theme = next;
      localStorage.setItem("theme", next);
      button.setAttribute("aria-pressed", String(next === "dark"));
    });
  }

  setupToggle();
</script>

<style>
  .theme-toggle {
    display: inline-grid;
    place-items: center;
    width: 2.5rem;
    height: 2.5rem;
    border: 1px solid var(--color-border);
    border-radius: 999px;
    background: transparent;
    color: inherit;
    cursor: pointer;
  }

  .icon-dark {
    display: none;
  }

  :global([data-theme="dark"]) .icon-light {
    display: none;
  }

  :global([data-theme="dark"]) .icon-dark {
    display: inline;
  }
</style>

How it works:

  • currentTheme() reads the explicit choice if there is one, and otherwise asks the browser what the system prefers. That way the first click always switches to the opposite of what the visitor currently sees.
  • aria-pressed exposes the state to screen readers, and the aria-label describes the action.
  • :global([data-theme="dark"]) lets the component's scoped styles react to an attribute on <html>, which the component doesn't own.

Note that the icons only switch based on data-theme. If you want them to reflect the system preference before anyone clicks, add matching rules inside a prefers-color-scheme: dark media query. Writing interactive components with plain scripts like this is covered further in the post on interactivity without a framework.

What I Observed

Running the built site in headless Chrome, the background colour of body was:

OS dark, no storage: [ '(none)', 'rgb(13, 17, 23)', 'true' ]
OS light, no storage: [ '(none)', 'rgb(255, 255, 255)', 'false' ]
after click: [ 'dark', 'rgb(13, 17, 23)', 'true' ]
after reload: [ 'dark', 'rgb(13, 17, 23)', 'true' ]

Each line shows the data-theme value, the computed background and the button's aria-pressed value. The system preference was respected, the click switched the theme, and the choice survived a reload.

Step 6: Support ClientRouter Navigation

If your site uses <ClientRouter /> from astro:transitions for view transitions, navigation no longer reloads the page. The router swaps in the new document, including the attributes on <html>. With the code above unchanged, my test after clicking a link showed:

after nav: http://localhost:4406/about [ '(none)', 'rgb(255, 255, 255)', null ]

Two things broke. The data-theme attribute was lost, because the new page's <html> doesn't have it, and the toggle stopped responding, because bundled scripts only run once and the button is a new element after the swap.

Fix the Theme with astro:before-swap

The astro:before-swap event gives you the incoming document before it replaces the current one, so you can set the attribute on it:

---
// src/components/ThemeScript.astro
---
<script is:inline>
  (() => {
    const apply = (doc) => {
      const stored = localStorage.getItem("theme");
      if (stored === "light" || stored === "dark") {
        doc.documentElement.dataset.theme = stored;
      }
    };

    apply(document);

    if (!window.__themeSwapListener) {
      window.__themeSwapListener = true;
      document.addEventListener("astro:before-swap", (event) => apply(event.newDocument));
    }
  })();
</script>

The guard on window.__themeSwapListener matters because inline scripts can run again during a visit. Without it, each run would add another listener.

Re-initialise the Toggle with astro:page-load

In ThemeToggle.astro, replace the direct setupToggle() call with:

document.addEventListener("astro:page-load", setupToggle);

astro:page-load fires on the first load and after every navigation, so the new button gets its listener each time. It only fires when <ClientRouter /> is on the page; without the router, keep the plain setupToggle() call.

With both changes, the test passed across navigation:

after nav: http://localhost:4406/about [ 'dark', 'rgb(13, 17, 23)', 'true' ]
after 2nd click: [ 'light', 'rgb(255, 255, 255)', 'false' ]

Since Astro 7, the old TRANSITION_* constants exported for these event names are gone, so use the string names like "astro:before-swap" directly. The post on view transitions in Astro covers the full event lifecycle.

Using Dark Mode with Tailwind CSS

Tailwind's dark: variant follows prefers-color-scheme by default. To make it follow your data-theme attribute instead, redefine the variant in your Tailwind stylesheet:

/* src/styles/global.css */
@import "tailwindcss";

@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

Then classes like dark:bg-slate-950 apply whenever <html data-theme="dark"> is set, and the same inline script and toggle work unchanged. With this variant, the system preference no longer triggers dark: classes on its own, so if you want that default, have the inline script set data-theme from matchMedia when nothing is stored. The setup is described in the post on Tailwind CSS in Astro.

Images, Code Blocks and Other Details

A few finishing touches make dark mode feel complete:

  • Swap images with <picture>: a <source media="(prefers-color-scheme: dark)"> serves a dark logo, though it follows the system, not your toggle. For toggle-aware logos, render both and hide one with [data-theme] selectors, or use an SVG with fill="currentColor".
  • Syntax highlighting: Astro's built-in Shiki highlighter supports dual themes through markdown.shikiConfig.themes, which outputs CSS variables you can switch with your theme attribute.
  • Avoid pure black: very dark greys like #0d1117 are easier on the eyes and leave room for shadows and borders.
  • Check contrast in both themes: links and muted text often pass in light mode and fail in dark mode, or the other way round.

FAQ: Dark Mode in Astro

Define colours as CSS custom properties and override them inside a prefers-color-scheme dark media query. The site then follows the operating system's theme with no script at all. JavaScript is only needed for a manual toggle.

The saved theme is being applied by a script that runs after the first paint. Move it into an is:inline script in the head, which runs before the body renders. Normal Astro scripts are bundled modules and run too late for this.

Either works. A data-theme attribute keeps theme state separate from styling classes and can hold more than two values. If you use Tailwind, configure its dark variant to match whichever you choose.

The router replaces the html element's attributes with those of the new page. Listen for astro:before-swap and set the theme on event.newDocument, and re-attach toggle listeners on astro:page-load.

localStorage is the usual choice for a static site because the inline script can read it synchronously. If you render pages on demand and want the server to send the right theme, a cookie works too.

Yes, if you set the color-scheme property. With color-scheme set to light dark, or switched per theme, browsers style built-in controls, scrollbars and the default background to match.


Conclusion

A robust dark mode in Astro is mostly CSS: colour tokens on :root, dark values in a prefers-color-scheme media query, and overrides for an explicit data-theme attribute. That gives every visitor a theme that matches their system with zero JavaScript, and makes every component theme-aware as long as it uses the variables.

The toggle adds two small scripts: an inline one in the head that applies the saved choice before the first paint, and a bundled one that flips and stores it. If you use <ClientRouter />, carry the theme across navigations with astro:before-swap and re-initialise the toggle on astro:page-load. With those pieces, dark mode works on first load, after reloads and across client-side navigation without a flash.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper