How the Pi-Web CSS Theme System Works with CSS Variables

Pi-Web uses a lightweight, CSS-variable-based theming approach that enables instant light/dark mode switching by toggling a class on the <html> element and swapping underlying token values.

The agegr/pi-web repository implements a modern theming system without JavaScript style injection or heavy CSS-in-JS libraries. Instead, it leverages native CSS custom properties (variables) combined with a React hook for state management. This architecture delivers zero-flash theme changes, automatic OS synchronization, and optional view-transition animations.


CSS Variable Architecture in globals.css

All design tokens live in app/globals.css with a two-layer structure: raw value tokens and semantic aliases.

Raw Token Definition

The file declares parallel sets of variables—one for light mode under :root, another for dark mode under html.dark:

:root {
  --bg: #ffffff;
  --text: #1a1a1a;
  --border: #e5e5e5;
  --bg-panel: #f5f5f5;
  --text-muted: #737373;
  --accent: #3b82f6;
}

html.dark {
  --bg: #1a1a1a;
  --text: #ffffff;
  --border: #333333;
  --bg-panel: #262626;
  --text-muted: #a3a3a3;
  --accent: #60a5fa;
}

Semantic Alias Mapping with @theme

The @theme block creates stable component-facing names that map to the swappable tokens:

@theme {
  --color-bg: var(--bg);
  --color-text: var(--text);
  --color-border: var(--border);
  --color-bg-panel: var(--bg-panel);
  --color-text-muted: var(--text-muted);
  --color-accent: var(--accent);
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}

This abstraction lets components reference --color-bg while the actual background value switches automatically when the dark class is applied.


Theme State Management with useTheme.ts

The hooks/useTheme.ts hook orchestrates preference storage, DOM class application, and system synchronization.

LocalStorage Persistence and In-Memory State

The hook stores the user's preference ("light", "dark", or "auto") in localStorage["pi-theme"] and exposes a reactive state object to components.

Applying the Dark Class

When the theme changes, applyDomTheme() toggles the dark class on the document root:

function applyDomTheme(theme: ResolvedTheme): void {
  if (typeof document === "undefined") return;
  document.documentElement.classList.toggle("dark", theme === "dark");
}

Adding dark activates the html.dark selector block in globals.css, instantly repainting all components that reference CSS variables.

Automatic System Mode

When preference is "auto", the hook listens to the browser's (prefers-color-scheme: dark) media query:

// Simplified logic from useTheme.ts
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");

function handleChange(e: MediaQueryListEvent) {
  const systemTheme: ResolvedTheme = e.matches ? "dark" : "light";
  applyDomTheme(systemTheme);
  setState({ theme: "auto", resolved: systemTheme });
}

mediaQuery.addEventListener("change", handleChange);

OS-level theme changes propagate to Pi-Web without page reload.


Component Consumption of CSS Variables

Components throughout Pi-Web reference variables directly in inline styles or CSS modules. This eliminates prop drilling and ensures consistent theming.

Example: TabBar.tsx

The components/TabBar.tsx file demonstrates typical variable usage:

<div
  style={{
    background: "var(--bg-panel)",
    borderRight: "1px solid var(--border)",
    color: isActive ? "var(--text)" : "var(--text-muted)",
  }}
>
  {/* tab content */}
</div>

Example: Theme Toggle Button

import { useTheme } from "@/hooks/useTheme";

export function ThemeToggle() {
  const { toggleTheme, isDark } = useTheme();
  
  return (
    <button
      onClick={e => toggleTheme({ x: e.clientX, y: e.clientY })}
      style={{
        background: isDark ? "var(--bg)" : "var(--bg-panel)",
        color: isDark ? "var(--text)" : "var(--text-muted)",
        fontFamily: "var(--font-mono)",
        padding: "0.5rem 1rem",
        borderRadius: "0.375rem",
        border: "1px solid var(--border)",
      }}
    >
      {isDark ? "Switch to light" : "Switch to dark"}
    </button>
  );
}

Additional examples appear in components/SkillsConfig.tsx (background: "var(--accent)") and components/SessionSidebar.tsx (var(--text-muted), var(--bg-hover)).


View-Transition Animation Support

When the browser supports the View Transitions API, useTheme.toggleTheme() wraps the class change in a circular reveal effect. The CSS variables still drive all color values—the animation only controls how the visual transition is presented:

// From useTheme.ts - simplified
async function toggleTheme(clickCoords: { x: number; y: number }) {
  if (!document.startViewTransition) {
    setTheme(nextTheme); // Immediate fallback
    return;
  }

  await document.startViewTransition(() => {
    setTheme(nextTheme);
    applyDomTheme(nextTheme);
  }).ready;
  
  // Custom circular wipe animation using clickCoords...
}

This progressive enhancement ensures smooth theme switches on supported browsers without breaking functionality elsewhere.


How the System Works: Complete Flow

Step Action File
Define tokens Declare --bg, --text, etc. for light (:root) and dark (html.dark) app/globals.css
Map aliases Create --color-* semantic names via @theme app/globals.css
Store preference Read/write localStorage["pi-theme"] hooks/useTheme.ts
Apply class Toggle dark class on <html> via applyDomTheme() hooks/useTheme.ts
CSS cascade All var(--...) references repaint automatically Component styles
Sync with OS Listen to prefers-color-scheme changes hooks/useTheme.ts
Animate Optional view-transition effect hooks/useTheme.ts

Summary

  • Token-based architecture in app/globals.css separates raw values from semantic aliases, enabling clean theme separation
  • Zero-JS style injection—all colors switch via CSS cascade when the dark class changes
  • Persistent preferences stored in localStorage and managed by hooks/useTheme.ts
  • Automatic system sync for "auto" mode via media query listeners
  • Native performance without runtime CSS generation or heavy dependencies
  • Progressive animation via View Transitions API for supported browsers

Frequently Asked Questions

How does Pi-Web avoid flash of unstyled content during theme initialization?

The system reads localStorage["pi-theme"] and applies the dark class synchronously before React hydration. Since CSS variables are defined in globals.css loaded in the document <head>, no JavaScript execution is required for the initial paint to use correct colors.

Can I add additional themes beyond light and dark?

The current implementation supports binary switching via the dark class. Extending to multiple themes would require modifying applyDomTheme() to set a data attribute (e.g., data-theme="sepia") and expanding the variable definitions in globals.css with corresponding selectors like html[data-theme="sepia"].

Why use CSS variables instead of Tailwind's dark mode classes?

Pi-Web uses Tailwind's @theme directive with CSS variable references rather than dark: utility prefixes. This approach centralizes color values in one location, enables runtime theme switching without rebuilding styles, and supports the "auto" system-preference mode with minimal JavaScript.

What browsers support the view-transition animation?

The View Transitions API is currently supported in Chromium-based browsers (Chrome, Edge, Opera) as of version 111+. Pi-Web gracefully degrades to immediate theme switching when document.startViewTransition is unavailable, ensuring functionality across all modern browsers.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →