When Themes Don’t Change: Debugging Astro, CSS Variables, and Vim Keybindings

When Themes Don’t Change: Debugging Astro, CSS Variables, and Vim Keybindings

2026-01-16T00:00:00.000Z
2026-01-16T00:00:00.000Z
Updated on

You know that feeling when everything should be working, but the browser stubbornly refuses to cooperate?

This is a small debugging story about exactly that: an Astro site that happily toggled dark/light mode and logged theme changes when you pressed t or T, yet visually stayed locked in the same theme.

The quick fix turned out to be a neat combo of:

  • CSS variables
  • Selector specificity
  • data-theme attributes
  • And a bit of Vim-style keyboard navigation glue.

If you’re wiring up themes in Astro (or any modern SPA/MPA with CSS variables), this is exactly the kind of subtle bug you’re likely to run into.


The Symptom: Keybindings Worked, UI Didn’t

The site in question has Vim-like keyboard navigation handled by a small script at:

export function initVimNavigation() {
  document.addEventListener("DOMContentLoaded", () => {
    // ...
    const themes = ["default", "purple", "yellow", "gold", "rose-gold", "lime"];
    let currentThemeIndex = 0;

    function applyTheme(themeName) {
      const themeLink = document.getElementById("theme-stylesheet");
      // ...
    }

    document.addEventListener("keydown", (e) => {
      if (e.key === "t" || e.key === "T") {
        e.preventDefault();
        const newIndex =
          (currentThemeIndex + (e.key === "t" ? 1 : -1) + themes.length) %
          themes.length;
        applyTheme(themes[newIndex]);
        return;
      }
      // ...
    });
  });
}

Pressing t or T is supposed to cycle through the available themes by switching the active stylesheet. The code did exactly that: on each keypress, the debug logs clearly showed:

  • applyTheme being called
  • The href of the theme <link> element changing to /themes/purple.css, /themes/gold.css, etc.
  • The theme index updating

But the UI never changed color.

At the same time:

  • The dark/light mode toggle (m / M) did work.
  • No JavaScript errors appeared in the console.
  • The theme stylesheets were loading (no 404s).

The wiring looked correct… so where was the missing link?


The initial theming architecture used a dedicated <link> tag in the <head>:

<link rel="stylesheet" href="/styles/fonts.css" />
<link id="theme-stylesheet" rel="stylesheet" />

The Vim navigation script assumed this link existed:

function applyTheme(themeName) {
  const themeLink = document.getElementById("theme-stylesheet");
  if (!themeLink) {
    console.error("Theme stylesheet link not found!");
    return;
  }

  if (themeName && themeName !== "default") {
    themeLink.setAttribute("href", `/themes/${themeName}.css`);
  } else {
    themeLink.removeAttribute("href");
  }

  localStorage.setItem("theme", themeName);
  currentThemeIndex = themes.indexOf(themeName);
}

At some point, a pre-emptive inline script had been added to the same <head> to avoid FOUC (Flash of Unstyled Content). It looked up localStorage.theme and injected a theme <link> during initial HTML parsing:

<script is:inline>
  const theme = (() => {
    if (typeof localStorage !== "undefined" && localStorage.getItem("theme")) {
      return localStorage.getItem("theme");
    }
    return "default";
  })();

  if (theme !== "default") {
    const link = document.createElement("link");
    link.rel = "stylesheet";
    link.href = `/themes/${theme}.css`;
    link.dataset.theme = theme;
    document.head.appendChild(link);
  }
</script>

Spot the problem?

  • The inline script created a new <link>, with no id.
  • The original <link id="theme-stylesheet"> remained in the DOM with no href.
  • The Vim script manipulated #theme-stylesheet, but the browser was actually using the other link created at parse time.

So we had two separate theme links:

  1. One used at page load (anonymous <link>)
  2. One used by the keyboard handler (#theme-stylesheet), never wired to anything real

The fix here was straightforward: make both systems use the same link.

Fix 1: Reuse #theme-stylesheet in BaseHead

We updated the inline script so that it reuses the existing #theme-stylesheet element instead of creating a new one:

<script is:inline>
  const theme = (() => {
    if (
      typeof localStorage !== "undefined" &&
      localStorage.getItem("theme")
    ) {
      return localStorage.getItem("theme");
    }
    return "default";
  })();

  if (typeof document !== "undefined") {
    const themeLink = document.getElementById("theme-stylesheet");

    if (themeLink) {
      if (theme !== "default") {
        themeLink.rel = "stylesheet";
        themeLink.href = `/themes/${theme}.css`;
        themeLink.dataset.theme = theme;
        document.documentElement.dataset.theme = theme;
      } else {
        themeLink.removeAttribute("href");
        delete themeLink.dataset.theme;
        delete document.documentElement.dataset.theme;
      }
    }
  }
</script>

Now:

  • On initial load, the inline script sets the href on #theme-stylesheet.
  • At runtime, applyTheme modifies that same link.

That removed one source of inconsistency, and the debug logs confirmed we now had one authoritative theme link.

But the UI still didn’t visibly change.


Second Clue: “Source Shows theme-stylesheet Before Global CSS”

Viewing the page source revealed this order:

<link id="theme-stylesheet" rel="stylesheet" href="/themes/purple.css">
<!-- ... other tags ... -->
<style>/* global.css bundle defining :root variables */</style>

Important detail: the site uses CSS custom properties (--text-color, --accent-color, etc.) for theming:

:root {
  /* Dark Mode (Default) */
  --bg-color: #0d0208;
  --text-color: #00ff41;
  --accent-color: #00ff41;
  --accent-dark: #50ff50;
  --border-color: #008f11;
  --text-shadow: 0 0 5px rgba(0, 255, 65, 0.5);
  --text-shadow-hover: 0 0 10px rgba(80, 255, 80, 0.8);
  --summary-color: rgba(0, 255, 65, 0.7);
  /* ... light mode variants ... */
}

And each theme also defines its own values:

:root {
  /* Dark Mode Colors */
  --text-color: #da70d6;
  --accent-color: #da70d6;
  --accent-dark: #ffa0ff;
  --border-color: #8a2be2;
  --text-shadow: 0 0 5px rgba(218, 112, 214, 0.5);
  --text-shadow-hover: 0 0 10px rgba(255, 160, 255, 0.8);
  /* ... */
}

Here’s the kicker:

If global.css’s :root is loaded after the theme CSS, it simply overrides those custom properties.

So even though:

  • /themes/purple.css was loaded correctly, and
  • Its :root block did define purple-ish colors

Those definitions were overridden by the later :root from the global Astro bundle, which restored the default Matrix-green values.

From the browser’s perspective, everything worked as specified. From the user’s perspective, themes never appeared to change.


Third Clue: Light Mode Selectors Didn’t Match JS

The site also has a dark/light mode concept:

  • JS toggles a light-mode class on <html>:

    function applyMode(mode) {
      document.documentElement.classList.toggle("light-mode", mode === "light");
      localStorage.setItem("mode", mode);
      currentMode = mode;
    }
  • Global CSS uses body.light-mode to remap variables:

    body.light-mode {
      --bg-color: var(--bg-color-light);
      --text-color: var(--text-color-light);
      --accent-color: var(--accent-color-light);
      --accent-dark: var(--accent-dark-light);
      --card-bg: var(--card-bg-light);
      --border-color: var(--border-color-light);
      --text-shadow: var(--text-shadow-light);
      --text-shadow-hover: var(--text-shadow-hover-light);
      --summary-color: var(--summary-color-light);
    }
  • And the theme CSS used body.light-mode as well:

    /* Apply light mode overrides when .light-mode is present */
    body.light-mode {
      --text-color: var(--text-color-light);
      --accent-color: var(--accent-color-light);
      --accent-dark: var(--accent-dark-light);
      --border-color: var(--border-color-light);
      --text-shadow: var(--text-shadow-light);
      --text-shadow-hover: var(--text-shadow-hover-light);
      --summary-color: var(--summary-color-light);
    }

At one point, dark/light mode had been refactored to use html.light-mode (consistent with the article describing the architecture), but the theme CSS hadn’t been updated, which meant theme-specific light-mode overrides were also mismatched.

So we had three independent issues:

  1. Two different theme <link> elements.
  2. Astro’s global CSS overriding theme custom properties because of load order.
  3. light-mode class on <html> while theme CSS watched body.light-mode.

The Final Fix: data-theme + Specific Selectors

Instead of trying to micromanage load order, the more robust solution was to make the theme CSS more specific, using a data-theme attribute on <html>.

Step 1: Scope theme variables by data-theme

We changed each theme file to:

:root[data-theme="purple"] {
  /* Dark Mode Colors */
  --text-color: #da70d6;
  --accent-color: #da70d6;
  --accent-dark: #ffa0ff;
  --border-color: #8a2be2;
  --text-shadow: 0 0 5px rgba(218, 112, 214, 0.5);
  --text-shadow-hover: 0 0 10px rgba(255, 160, 255, 0.8);

  /* Light Mode Colors */
  --text-color-light: #8a2be2;
  --accent-color-light: #9932cc;
  --accent-dark-light: #da70d6;
  --border-color-light: #ba55d3;
  --text-shadow-light: 0 0 3px rgba(138, 43, 226, 0.3);
  --text-shadow-hover-light: 0 0 5px rgba(153, 50, 204, 0.5);
  --summary-color: rgba(218, 112, 214, 0.7);
  --summary-color-light: rgba(138, 43, 226, 0.7);
}

/* Apply light mode overrides when .light-mode is present */
html.light-mode[data-theme="purple"] {
  --text-color: var(--text-color-light);
  --accent-color: var(--accent-color-light);
  --accent-dark: var(--accent-dark-light);
  --border-color: var(--border-color-light);
  --text-shadow: var(--text-shadow-light);
  --text-shadow-hover: var(--text-shadow-hover-light);
  --summary-color: var(--summary-color-light);
}

Equivalent updates were made for:

  • gold: :root[data-theme="gold"] / html.light-mode[data-theme="gold"]
  • lime: :root[data-theme="lime"] / html.light-mode[data-theme="lime"]
  • rose-gold: :root[data-theme="rose-gold"] / html.light-mode[data-theme="rose-gold"]
  • yellow: :root[data-theme="yellow"] / html.light-mode[data-theme="yellow"]

Because :root[data-theme="purple"] is more specific than plain :root, it wins in the cascade even if global.css is loaded later.

Step 2: Set data-theme on <html> in JS

We extended applyTheme to also toggle the attribute:

function applyTheme(themeName) {
  console.log(`[DEBUG] applyTheme called with: ${themeName}`); // DEBUG
  const themeLink = document.getElementById("theme-stylesheet");
  console.log(`[DEBUG] themeLink found:`, themeLink); // DEBUG
  if (!themeLink) {
    console.error("Theme stylesheet link not found!");
    return;
  }

  if (themeName && themeName !== "default") {
    themeLink.setAttribute("href", `/themes/${themeName}.css`);
    document.documentElement.dataset.theme = themeName;
    console.log(`[DEBUG] Theme link href set to: ${themeLink.href}`); // DEBUG
  } else {
    themeLink.removeAttribute("href");
    delete document.documentElement.dataset.theme;
    console.log(`[DEBUG] Theme link href removed.`); // DEBUG
  }

  localStorage.setItem("theme", themeName);
  currentThemeIndex = themes.indexOf(themeName);
  console.log(`[DEBUG] Theme index updated to: ${currentThemeIndex}`); // DEBUG
}

And the pre-emptive script in BaseHead.astro was updated to set data-theme on first paint as well (shown earlier).

Now, when you:

  • Press t / T:

    • href of #theme-stylesheet points to the right CSS file.
    • <html data-theme="purple"> (or gold, etc.) is set.
    • :root[data-theme="..."] overrides the base variables, regardless of load order.
  • Press m / M:

    • <html class="light-mode" data-theme="purple"> (for example) is set.
    • html.light-mode[data-theme="purple"] overrides the theme’s light-mode custom properties.

And crucially: you immediately see the theme change.


Why the Fix Was Fast

Even though there were a few intertwined issues, the debugging was quick because we leaned on a simple mental model:

  1. Is the event firing?
    Yes — debug logs and currentThemeIndex changes confirmed the key handler and applyTheme were running.

  2. Is the DOM element being updated?
    Yes — #theme-stylesheet.href changed as expected.

  3. Is the CSS actually winning in the cascade?
    No — global :root from global.css loaded later, overriding theme variables.

  4. Do selectors match the JS state?
    Not initially — JS toggled html.light-mode, CSS watched body.light-mode.

Once those pieces were clear, the fix naturally fell into:

  • Unifying on a single <link> element.
  • Making theme rules more specific via data-theme.
  • Aligning CSS selectors with how JS toggles classes/attributes.

Takeaways for Your Own Astro Theme System

If you’re doing anything similar, a few practical lessons:

  1. Use a single source of truth for theme stylesheets.
    One <link id="theme-stylesheet"> that both pre-emptive scripts and runtime JS agree on keeps your mental model simple.

  2. Prefer attribute-scoped CSS variables over relying on load order.
    :root[data-theme="foo"] and html.light-mode[data-theme="foo"] give you deterministic behavior, even as tooling shuffles <style> blocks around.

  3. Make JS and CSS talk the same language.
    If JS toggles html.light-mode and data-theme="purple", then your CSS should use html.light-mode[data-theme="purple"] (or similar), not body.light-mode.

  4. Debugging flow matters more than tools.
    The sequence — check events → check DOM updates → check custom property cascade — works across frameworks and build systems.

If you’ve got a theme switcher that “works” in the logs but not on screen, inspect:

  • The actual <link> tags and their hrefs,
  • The order of your <style> / <link> blocks,
  • And which custom property definitions “win” in the computed styles.

Chances are, you’re only a data-theme and a selector tweak away from a clean, robust solution.

HQ Key Mappings

gg / G Jump to top/bottom of page
j / k Scroll page up/down
h / l Focus navigation bar and move left/right
c / Escape Remove focus from nav bar / Close modal
x / Enter Follow a focused navigation link
t / T Cycle through site themes
m / M Toggle light/dark mode
f / / Open search dialog
? Open help dialog