How Theme Switching and CSS Variables Work in pi-web
Pi‑web implements a light/dark theme system using a React hook that toggles a CSS class on the document root, while visual styles rely on CSS custom properties defined in globals.css that automatically update when the theme changes.
The pi-web repository handles theme switching through a coordinated system involving client-side storage, React state management, and CSS custom properties. This architecture ensures that theme preferences persist across sessions while delivering instant visual updates without JavaScript re-rendering overhead. The implementation spans four key files that handle initialization, state management, UI integration, and styling.
Architecture Overview
The theme system operates on a simple but effective pattern: a dark class toggled on document.documentElement drives all visual changes through CSS variables. When this class is present, the browser automatically applies dark mode values; when absent, light mode defaults take effect. This approach leverages the browser's native CSS variable engine for zero-cost runtime theme updates.
The useTheme Hook
The core logic resides in hooks/useTheme.ts, which exposes theme state and a toggle function to the React component tree.
The hook subscribes to theme changes using useSyncExternalStore, ensuring compatibility with React 18+ concurrent features and server-side rendering. It reads the current theme via a getSnapshot function that checks for the presence of the dark class on the document root.
// hooks/useTheme.ts – core logic
export function useTheme() {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const toggleTheme = useCallback((origin) => {
const next = getSnapshot() === "dark" ? "light" : "dark";
// Apply class and persist
if (next === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
localStorage.setItem("pi-theme", next);
listeners.forEach(cb => cb());
}, []);
return { theme, toggleTheme, isDark: theme === "dark" };
}
The toggleTheme function optionally leverages the experimental View‑Transition API to animate the theme change smoothly. When this API is unavailable, it falls back to immediate class toggling. The new theme value is immediately persisted to localStorage under the key pi-theme.
CSS Variable System
All visual theming relies on CSS custom properties declared in app/globals.css. The file defines parallel variable sets scoped to :root for light mode and .dark for dark mode.
/* app/globals.css – variable definitions */
:root {
--bg: #ffffff;
--bg-panel: #f5f5f5;
--bg-hover: #eeeeee;
--bg-selected: #e8e8e8;
}
.dark {
--bg: #1a1a1a;
--bg-panel: #242424;
--bg-hover: #2e2e2e;
--bg-selected: #383838;
}
/* Component usage */
.main-panel {
background: var(--bg);
}
Because components reference these variables via var(--bg) and similar declarations, theme changes require no JavaScript intervention in the render cycle. The browser recomputes styles instantly when the dark class toggles.
Initialization and Persistence
To prevent flash of unstyled content or incorrect initial themes, app/layout.tsx injects a blocking script that runs before React hydration.
This script checks localStorage for the pi-theme key and applies the dark class to document.documentElement if the saved value equals "dark". This ensures the correct theme renders immediately, eliminating any visual flicker during page load.
// Inline script in app/layout.tsx (line 28)
<script dangerouslySetInnerHTML={{
__html: `
const theme = localStorage.getItem('pi-theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
`
}} />
UI Integration
The AppShell.tsx component consumes the useTheme hook and wires it to the theme toggle button in the application's top navigation bar.
// components/AppShell.tsx (lines 35-36)
const { isDark, toggleTheme } = useTheme();
<button onClick={toggleTheme}>
{isDark ? 'Switch to Light' : 'Switch to Dark'}
</button>
This separation of concerns keeps the UI components agnostic of storage mechanisms and CSS implementation details, focusing solely on presentation and user interaction.
Summary
- State Management: Theme state lives in
document.documentElement.classListandlocalStorageunder the keypi-theme. - React Hook:
useThemeinhooks/useTheme.tsprovidesisDark,theme, andtoggleThemeviauseSyncExternalStore. - CSS Variables:
app/globals.cssdefines light and dark variable sets under:rootand.darkselectors. - Hydration Safety:
app/layout.tsxincludes an inline script to restore the theme before React hydrates. - Performance: Visual updates occur through CSS variable recomputation rather than React re-renders.
Frequently Asked Questions
How does pi-web prevent theme flickering on page load?
A synchronous script in app/layout.tsx checks localStorage for the pi-theme value and adds the dark class to the document root immediately during HTML parsing. This executes before React hydration, ensuring the correct CSS variables apply before the user sees the page.
Why does the useTheme hook use useSyncExternalStore instead of useState?
The useSyncExternalStore hook treats the DOM class list as an external mutable source, allowing React to safely subscribe to changes that might occur outside its control. This pattern supports concurrent rendering features and prevents hydration mismatches between server and client environments.
Where are the theme colors actually defined?
All color definitions reside in app/globals.css. Light mode values are assigned to the :root selector, while dark mode overrides are scoped under the .dark class. Components reference these variables using standard var(--variable-name) syntax throughout the stylesheet.
Does pi-web support animated theme transitions?
Yes. The toggleTheme function in hooks/useTheme.ts attempts to use the experimental View‑Transition API to animate the theme change. If the browser does not support this API, it gracefully falls back to immediate class toggling without animation.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →