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

> Discover how Pi-Web's CSS theme system uses CSS variables for instant light/dark mode switching. Learn to swap token values by toggling a class on the html element.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: internals
- Published: 2026-08-15

---

**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`](https://github.com/agegr/pi-web/blob/main/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`:

```css
: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:

```css
@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`](https://github.com/agegr/pi-web/blob/main/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:

```typescript
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:

```typescript
// 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`](https://github.com/agegr/pi-web/blob/main/components/TabBar.tsx)** file demonstrates typical variable usage:

```tsx
<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

```tsx
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`](https://github.com/agegr/pi-web/blob/main/components/SkillsConfig.tsx)** (`background: "var(--accent)"`) and **[`components/SessionSidebar.tsx`](https://github.com/agegr/pi-web/blob/main/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:

```typescript
// 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`](https://github.com/agegr/pi-web/blob/main/app/globals.css) |
| **Map aliases** | Create `--color-*` semantic names via `@theme` | [`app/globals.css`](https://github.com/agegr/pi-web/blob/main/app/globals.css) |
| **Store preference** | Read/write `localStorage["pi-theme"]` | [`hooks/useTheme.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useTheme.ts) |
| **Apply class** | Toggle `dark` class on `<html>` via `applyDomTheme()` | [`hooks/useTheme.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useTheme.ts) |
| **CSS cascade** | All `var(--...)` references repaint automatically | Component styles |
| **Sync with OS** | Listen to `prefers-color-scheme` changes | [`hooks/useTheme.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useTheme.ts) |
| **Animate** | Optional view-transition effect | [`hooks/useTheme.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useTheme.ts) |

---

## Summary

- **Token-based architecture** in [`app/globals.css`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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.