How useTheme Manages Light/Dark Mode and Persists Preferences in pi-web
The useTheme hook in pi-web uses React's useSyncExternalStore to monitor the dark class on the document root, persists user preferences to localStorage under the key pi-theme, and optionally triggers smooth View Transition API animations when toggling between light and dark modes.
The useTheme hook serves as the central theming engine for the pi-web React application, handling everything from initial theme detection to cross-session persistence. Located in hooks/useTheme.ts, this lightweight utility synchronizes the UI with the browser's storage and DOM while gracefully handling server-side rendering (SSR) edge cases.
Understanding the useTheme Hook Architecture
Theme Detection and SSR Safety
The hook determines the current theme by inspecting the dark class on document.documentElement. In hooks/useTheme.ts, the getSnapshot() function checks document.documentElement.classList.contains("dark") to return either "dark" or "light" (lines 16-19).
For SSR compatibility, getServerSnapshot() hardcodes a fallback to "light" (lines 21-23). This prevents hydration mismatches and eliminates flashes of incorrect styling during initial page loads when the code executes on the server.
External Store Subscription
To ensure components re-render when the theme changes, useTheme leverages React's useSyncExternalStore. The implementation maintains a global Set of listener callbacks referenced as listeners in the source.
The subscribe function adds and removes callbacks from this set (lines 9-13), allowing useSyncExternalStore to consume these subscriptions (lines 27-28) and trigger updates across the component tree whenever the theme toggles.
Toggling Themes with Persistent Storage
localStorage Persistence Strategy
When users initiate a theme change, toggleTheme calculates the next state using the expression next = getSnapshot() === "dark" ? "light" : "dark" (lines 30-32). It then immediately updates the DOM by adding or removing the dark class and writes the preference to localStorage under the key pi-theme (lines 34-41).
The storage operation is wrapped in a try…catch block (lines 39-43) to safely handle private browsing restrictions or storage quotas where localStorage access might throw errors.
View Transition API Animation
For supported browsers, toggleTheme creates a circular reveal animation anchored at the click coordinates (or screen center if no coordinates are provided). The implementation detects View Transition API support via supportsVT and checks for prefers-reduced-motion media queries (lines 47-50).
When available and permitted, the code calls document.startViewTransition combined with document.documentElement.animate to produce a smooth morphing effect between color schemes (lines 55-77). If the API is unavailable or the user prefers reduced motion, the theme switches instantly without animation.
Implementation Examples
Here is how to implement a theme toggle button that passes click coordinates for the transition animation:
// Example: a button that toggles the theme with a ripple effect
import { useTheme } from '@/hooks/useTheme';
export function ThemeToggleButton() {
const { isDark, toggleTheme } = useTheme();
const handleClick = (e: React.MouseEvent) => {
// Pass the click coordinates to get a centered transition animation
toggleTheme({ x: e.clientX, y: e.clientY });
};
return (
<button onClick={handleClick} className="p-2 rounded">
{isDark ? '🌙 Dark' : '☀️ Light'}
</button>
);
}
For applying theme-aware styles in components:
// Example: applying theme‑aware styles in a component
import { useTheme } from '@/hooks/useTheme';
export function Header() {
const { theme } = useTheme(); // "light" or "dark"
return (
<header className={theme === 'dark' ? 'bg-gray-800 text-white' : 'bg-white text-gray-900'}>
<h1>pi‑web</h1>
</header>
);
}
Summary
- SSR-safe detection:
useThemereads thedarkclass fromdocument.documentElementon the client while defaulting to"light"during server-side rendering to prevent hydration errors. - External store pattern: The hook uses
useSyncExternalStorewith a globalSetof listeners to broadcast theme changes to all subscribed components. - Persistent storage: User preferences are saved to
localStorageunder the keypi-themewith error handling for restricted browsing modes. - Smooth transitions: Optional View Transition API support creates circular reveal animations when toggling themes, with automatic fallback for unsupported browsers or reduced-motion preferences.
- Simple API: The hook returns
{ theme, toggleTheme, isDark }for reading the current state and initiating animated or instant theme switches.
Frequently Asked Questions
How does useTheme handle server-side rendering?
The hook provides a getServerSnapshot() function that returns "light" unconditionally (lines 21-23 in hooks/useTheme.ts). This ensures the server-rendered HTML matches the initial client state, preventing hydration mismatches and visual flashes when React hydrates the application.
Where does pi-web store theme preferences?
Preferences are persisted in the browser's localStorage under the key pi-theme (line 38). This allows the theme choice to survive page reloads and new sessions. The hook reads this storage during initialization to restore the user's previous selection.
What happens if localStorage is unavailable?
The toggleTheme function wraps storage operations in a try…catch block (lines 39-43). If writing to localStorage fails—such as in private browsing modes or when storage quotas are exceeded—the theme still toggles in the DOM and memory, but the preference won't persist across sessions.
Does useTheme support animation when switching themes?
Yes, when the browser supports the View Transition API and the user hasn't requested reduced motion, toggleTheme creates a circular reveal animation centered on the click coordinates. The implementation uses document.startViewTransition combined with document.documentElement.animate (lines 55-77) to produce the effect. If the API is unavailable or accessibility preferences require reduced motion, the theme switches instantly 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 →