Skip to main content
Chapinismos implements a theme switching system that allows users to toggle between dark and light modes with persistent preferences stored in localStorage.

Theme Toggle Component

The theme toggle is implemented in src/components/ThemeToggle.astro with support for both desktop and mobile versions.

Component Structure

---
import { Moon, Sun } from "@lucide/astro";
interface Props {
  isMobile?: boolean;
}

const { isMobile = false } = Astro.props;
const buttonId = isMobile ? "theme-toggle-mobile" : "theme-toggle";
const moonId = isMobile ? "moon-mobile" : "moon";
const sunId = isMobile ? "sun-mobile" : "sun";
const device = isMobile ? "mobile" : "desktop";
---

<button
  id={buttonId}
  class="theme-toggle-btn inline-flex cursor-pointer items-center gap-2 px-2.5 py-2"
  type="button"
  aria-label="Cambiar tema"
  data-umami-event="Theme Toggle"
  data-umami-event-device={device}
>
  <Moon id={moonId} class="block" size="18" />
  <Sun id={sunId} class="hidden" size="18" />
</button>
Key features:
  • Uses Lucide icons for Moon/Sun
  • Supports separate desktop and mobile instances
  • Tracks analytics with Umami
  • Proper ARIA labels for accessibility

Theme Management Logic

The theme system uses inline scripts to prevent flash of unstyled content (FOUC).

Core Functions

getTheme()

Retrieves the saved theme preference or defaults to dark:
function getTheme() {
  return localStorage.getItem("theme") || "dark";
}

restoreTheme()

Applies the saved theme on page load:
function restoreTheme() {
  const theme = getTheme();
  el.setAttribute("data-theme", theme);
}
This sets the data-theme attribute on the <html> element:
<html lang="es" data-theme="dark">

updateIcons()

Toggles icon visibility based on current theme:
function updateIcons() {
  const t = el.getAttribute("data-theme");
  const m = document.getElementById("moon");
  const s = document.getElementById("sun");
  const mMobile = document.getElementById("moon-mobile");
  const sMobile = document.getElementById("sun-mobile");

  if (t === "light") {
    s?.classList.remove("hidden");
    m?.classList.add("hidden");
    sMobile?.classList.remove("hidden");
    mMobile?.classList.add("hidden");
  } else {
    s?.classList.add("hidden");
    m?.classList.remove("hidden");
    sMobile?.classList.add("hidden");
    mMobile?.classList.remove("hidden");
  }
}
Icon behavior:
  • Dark mode: Shows Moon icon
  • Light mode: Shows Sun icon
  • Updates both desktop and mobile versions

toggleTheme()

Switches between themes and persists the choice:
function toggleTheme() {
  const next = el.getAttribute("data-theme") === "dark" ? "light" : "dark";
  el.setAttribute("data-theme", next);
  localStorage.setItem("theme", next);
  updateIcons();
}
Flow:
  1. Check current theme
  2. Calculate opposite theme
  3. Update DOM attribute
  4. Save to localStorage
  5. Update icon visibility

Event Listener Setup

The component prevents duplicate listeners using a custom hasListener flag:
function setupThemeToggle() {
  const btn = document.getElementById("theme-toggle");
  const btnMobile = document.getElementById("theme-toggle-mobile");

  if (btn && !btn.hasListener) {
    btn.addEventListener("click", toggleTheme);
    btn.hasListener = true;
  }

  if (btnMobile && !btnMobile.hasListener) {
    btnMobile.addEventListener("click", toggleTheme);
    btnMobile.hasListener = true;
  }

  updateIcons();
}
This is crucial for Astro’s View Transitions to prevent multiple event listeners on the same button.

Initialization

The theme system initializes on page load and after navigation:
function initialize() {
  restoreTheme();
  setupThemeToggle();
}

// Setup on initial load
if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", initialize);
} else {
  initialize();
}

// Re-setup after each page navigation
document.addEventListener("astro:page-load", initialize);
Why this approach?
  • DOMContentLoaded listener: Handles initial page load
  • Direct initialize() call: Runs if script loads after DOM is ready
  • astro:page-load: Re-initializes after Astro View Transitions

CSS Variables Implementation

The theme system likely uses CSS custom properties for colors. While not shown in the component file, the typical pattern would be:
:root {
  /* Default theme (dark) */
  --color-bg: #1a1a1a;
  --color-text: #ffffff;
  --color-primary: #4997d0;
}

[data-theme="light"] {
  --color-bg: #ffffff;
  --color-text: #1a1a1a;
  --color-primary: #2563eb;
}
Components then reference these variables:
.card {
  background-color: var(--color-bg);
  color: var(--color-text);
}

LocalStorage Structure

The theme preference is stored as a simple key-value pair:
localStorage.setItem("theme", "dark"); // or "light"
Storage details:
  • Key: theme
  • Value: "dark" or "light"
  • Persists across sessions
  • Domain-specific

Preventing Flash of Unstyled Content (FOUC)

The component uses is:inline to execute immediately:
<script is:inline type="module">
  const el = document.documentElement;
  // Theme restoration happens before render
</script>
Why is:inline?
  • Script runs immediately, not deferred
  • Prevents bundling/minification
  • Executes before page renders
  • Eliminates theme flash on page load

View Transitions Compatibility

Astro’s View Transitions require special handling:
// Prevent duplicate listeners
if (btn && !btn.hasListener) {
  btn.addEventListener("click", toggleTheme);
  btn.hasListener = true;
}

// Re-initialize after navigation
document.addEventListener("astro:page-load", initialize);
Without this, navigating between pages would:
  • Add duplicate click listeners
  • Cause multiple theme toggles per click
  • Break icon synchronization

Analytics Integration

Theme toggles are tracked with custom events:
<button
  data-umami-event="Theme Toggle"
  data-umami-event-device="mobile"
>
This tracks:
  • How often users change themes
  • Desktop vs mobile usage
  • Theme preferences over time

Accessibility Features

<button
  type="button"
  aria-label="Cambiar tema"
>
Provides screen reader context for the button’s purpose.
Uses proper <button> elements with type="button" to prevent form submission.
Button is fully keyboard accessible:
  • Tab to focus
  • Enter/Space to toggle
Icons change based on current state, providing visual confirmation of the active theme.

Usage in Layout

Include the component in your layout:
---
import ThemeToggle from "../components/ThemeToggle.astro";
---

<header>
  <nav>
    <!-- Desktop version -->
    <ThemeToggle />
    
    <!-- Mobile version -->
    <ThemeToggle isMobile={true} />
  </nav>
</header>

Default Theme

The system defaults to dark mode:
function getTheme() {
  return localStorage.getItem("theme") || "dark";
}
Initial HTML:
<html lang="es" data-theme="dark">
From src/layouts/Base.astro:30

Best Practices

Always restore themes with inline scripts to prevent FOUC:
<script is:inline>
  // Theme restoration code
</script>
Re-initialize theme logic after navigation:
document.addEventListener("astro:page-load", initialize);
Track listener state to avoid duplicates:
if (btn && !btn.hasListener) {
  btn.addEventListener("click", toggleTheme);
  btn.hasListener = true;
}
Ensure desktop and mobile toggles stay in sync by:
  • Using the same localStorage key
  • Updating all icons in updateIcons()
  • Sharing the same data-theme attribute

Build docs developers (and LLMs) love