@maholan/theme
v0.2.4
Published
Theme provider and dark mode management for the MHL Untitled UI Platform.
Readme
@maholan/theme
Theme provider and dark mode management for the MHL Untitled UI Platform.
Features
- Light/Dark mode — explicit toggle or system-preference tracking
- Flash-free SSR —
<ThemeScript>Server Component eliminates flash of wrong theme - Persistent — user choice survives page refreshes via
localStorage - Stable
toggleMode— stable callback reference; never recreates on re-render - Transition suppression — optional
disableTransitionOnChangeprevents color-flicker forcedMode— pin a subtree to a specific mode (useful for Storybook previews)onModeChangecallback — hook into mode changes for analytics or state sync- CSP-ready —
nonceprop on bothThemeScriptandThemeProvider - Type-safe — full TypeScript with strict mode
Installation
pnpm add @maholan/theme @maholan/tokens
# peer dependencies
pnpm add react react-domQuick Start (Next.js App Router)
// app/layout.tsx
import { ThemeProvider, ThemeScript } from "@maholan/theme";
import "./globals.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
// suppressHydrationWarning is REQUIRED — ThemeScript writes the class before
// React hydrates, so the server-rendered attribute may differ from the client.
<html lang="en" suppressHydrationWarning>
<body>
{/*
ThemeScript MUST be the first child of <body>.
It runs synchronously before the first paint — zero content flash.
Options must mirror ThemeProvider exactly.
*/}
<ThemeScript defaultMode="light" storageKey="mhl-theme-mode" />
<ThemeProvider
defaultMode="light"
storageKey="mhl-theme-mode"
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}Toggle in a client component
"use client";
import { useTheme } from "@maholan/theme";
export function ThemeToggle() {
const { resolvedMode, toggleMode } = useTheme();
return (
<button onClick={toggleMode} aria-label="Toggle theme">
{resolvedMode === "light" ? "🌙 Dark" : "☀️ Light"}
</button>
);
}API Reference
<ThemeScript> — Server Component
Renders a tiny (~250 byte) blocking inline <script> that applies the correct
mode to <html> before the first paint. Has no runtime cost after load.
Place it as the first child of <body> and make sure <html> has
suppressHydrationWarning.
All props must match the corresponding <ThemeProvider> props exactly.
| Prop | Type | Default | Description |
| ------------------------- | ------------------------- | ------------------ | -------------------------------------------------------- |
| defaultMode | 'light' \| 'dark' | 'light' | Fallback when no stored preference exists |
| defaultSystemPreference | boolean | false | Fall back to OS preference instead of defaultMode |
| storageKey | string | 'mhl-theme-mode' | localStorage key to read the persisted preference from |
| attribute | 'class' \| 'data-theme' | 'class' | DOM attribute to write on <html> |
| forcedMode | 'light' \| 'dark' | undefined | Ignore all preferences — always use this mode |
| nonce | string | undefined | CSP nonce for the inline <script> tag |
Mode resolution priority
forcedMode(if set)- Stored
localStoragevalue - OS
prefers-color-scheme(only whendefaultSystemPreference={true}) defaultMode
<ThemeProvider> — Client Component
Provides theme context to your React tree. Must be a client component boundary
(it adds "use client" internally).
| Prop | Type | Default | Description |
| --------------------------- | --------------------------- | ------------------ | --------------------------------------------------------------------- |
| children | ReactNode | Required | Application subtree |
| defaultMode | 'light' \| 'dark' | 'light' | Mode when no stored preference exists and OS tracking is off |
| defaultSystemPreference | boolean | false | Follow OS preference on first load (when no stored preference exists) |
| storageKey | string | 'mhl-theme-mode' | localStorage key for persisting the preference |
| attribute | 'class' \| 'data-theme' | 'class' | DOM attribute written on <html> |
| forcedMode | 'light' \| 'dark' | undefined | Pin to a specific mode; disables all toggling |
| disableTransitionOnChange | boolean | false | Inject * { transition: none } during mode switch to prevent flicker |
| onModeChange | (mode: ThemeMode) => void | undefined | Callback fired after the DOM is updated with the new mode |
| nonce | string | undefined | CSP nonce forwarded to injected <style> tags |
defaultSystemPreferenceisfalseby default. This ensuresdefaultModeis always respected. Set it totrueonly if you want the theme to follow the user's OS setting when they have no stored preference.
useTheme() — Hook
Access theme context from any client component inside <ThemeProvider>.
import { useTheme } from "@maholan/theme";
const {
mode, // ThemeMode — the user's chosen mode
resolvedMode, // ThemeMode — the mode actually applied to the DOM
// equals forcedMode when forced; otherwise equals mode
systemPreference, // boolean — whether OS tracking is active
setMode, // (mode: ThemeMode) => void
toggleMode, // () => void — stable reference, safe in dependency arrays
setSystemPreference, // (enabled: boolean) => void
} = useTheme();mode vs resolvedMode
| Value | When to use |
| -------------- | ------------------------------------------------------ |
| mode | Display what the user has chosen in settings UI |
| resolvedMode | Determine what is visually active (e.g., icon to show) |
Examples
System Preference Settings Panel
"use client";
import { useTheme } from "@maholan/theme";
export function ThemeSettings() {
const { mode, resolvedMode, setMode, systemPreference, setSystemPreference } =
useTheme();
return (
<div className="space-y-4">
<h2>Appearance</h2>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={systemPreference}
onChange={(e) => setSystemPreference(e.target.checked)}
/>
Match system appearance
</label>
{!systemPreference && (
<div className="flex gap-2">
<button
onClick={() => setMode("light")}
aria-pressed={mode === "light"}
>
Light
</button>
<button
onClick={() => setMode("dark")}
aria-pressed={mode === "dark"}
>
Dark
</button>
</div>
)}
<p className="text-sm text-gray-500">
Currently displaying: <strong>{resolvedMode}</strong>
</p>
</div>
);
}Forced Mode (Storybook / Docs Previews)
// Force dark mode for a specific story or preview pane
<ThemeProvider forcedMode="dark">
<ComponentPreview />
</ThemeProvider>Analytics / State Sync
<ThemeProvider
defaultMode="light"
onModeChange={(mode) => {
analytics.track("theme_changed", { mode });
store.dispatch(setTheme(mode));
}}
>
{children}
</ThemeProvider>CSP with Nonce
// Retrieve nonce from your CSP middleware (Next.js example)
import { headers } from "next/headers";
export default async function RootLayout({ children }) {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeScript defaultMode="light" nonce={nonce} />
<ThemeProvider defaultMode="light" nonce={nonce}>
{children}
</ThemeProvider>
</body>
</html>
);
}data-theme Attribute (instead of class)
<ThemeScript attribute="data-theme" defaultMode="light" />
<ThemeProvider attribute="data-theme" defaultMode="light">
{children}
</ThemeProvider>/* Target with CSS */
:root {
--bg: #fff;
--fg: #111;
}
[data-theme="dark"] {
--bg: #111;
--fg: #fff;
}How It Works
Zero-Flash Architecture
Browser request
│
▼
Next.js renders HTML (SSR)
└─ ThemeScript injects inline <script> first in <body>
│
▼
Browser parses HTML, executes <script> synchronously
└─ Reads localStorage → applies "light" or "dark" to <html>
│
▼
Browser paints first frame (correct theme, no flash)
│
▼
React hydrates → ThemeProvider reads same localStorage value
└─ suppressHydrationWarning prevents mismatch warningsTheme Application on the DOM
<!-- attribute="class" (default) -->
<html class="dark">
<!-- attribute="data-theme" -->
<html data-theme="dark"></html>
</html>Mode Resolution Priority
Both ThemeScript and ThemeProvider resolve the initial mode using the same
priority chain so they always agree:
forcedMode— hard override- Stored
localStorage[storageKey]— explicit user choice - OS
prefers-color-scheme— only whendefaultSystemPreference={true} defaultMode— final fallback
System Preference Tracking
When systemPreference is true, the provider subscribes to
window.matchMedia('(prefers-color-scheme: dark)') and updates the theme
whenever the OS setting changes.
Calling setSystemPreference(true) clears the stored preference so the OS value
is in full control. Calling setSystemPreference(false) persists the current
mode to localStorage.
Tailwind CSS v4 Integration
/* globals.css */
@import "tailwindcss";
/* Register dark variant — applies when <html> has class="dark" */
@custom-variant dark (&:where(.dark, .dark *));
@layer base {
body {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
}
}<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Hello, world
</div>For Tailwind v3, set darkMode: ['class'] in tailwind.config.ts.
Troubleshooting
Still seeing a theme flash
Ensure <ThemeScript> is the first child of <body>, not in <head> and
not after any other elements.
Hydration mismatch warnings
Add suppressHydrationWarning to the <html> element (not <body>).
Wrong theme on first load
Verify that storageKey, attribute, defaultMode, and
defaultSystemPreference are identical on both <ThemeScript> and
<ThemeProvider>.
React 19 warning — "Encountered a script tag while rendering React component"
What it is: A dev-mode console warning (not an error) that React 19 emits
when a <script> element is rendered inside a React component on the client. It
appears on pages that Next.js App Router renders in a client context — most
commonly the root not-found.tsx (404 page).
Why it happens: ThemeScript is designed as a Server Component and should
only render during SSR. However, Next.js App Router re-renders the root layout
(which contains ThemeScript) client-side for 404 pages, which triggers the
React 19 check.
Is anything broken? No. The script has already executed synchronously during
SSR before the first paint. The client-side re-render of the <script> tag is a
visual no-op — browsers do not re-execute inline scripts on React re-renders.
The warning is informational only and does not appear in production builds.
Why typeof window !== "undefined" guard is not the fix: The test
environment (happy-dom) defines window, so this guard causes ThemeScript to
return null during tests — breaking all 25 unit tests that verify the script
content and priority chain.
Alternative approaches and why they are not used here:
| Approach | How it works | Why not used |
| ------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| CSS prefers-color-scheme only | No JS at all — browser applies OS preference via media query | User cannot override; no toggle support |
| Cookie-based (server-read) | Server reads a cookie, renders <html class="dark"> from the start | Requires server rendering on every request — incompatible with static export |
| light-dark() CSS function | Native CSS Level 5, no JS needed | No user override; browser support still maturing (Chrome 123+, Firefox 120+, Safari 17.5+) |
| next-themes library | Third-party wrapper — same inline <script> technique internally | Same React 19 warning applies; adds a dependency for no benefit |
| _document.tsx (Pages Router) | <script> in _document is outside the React tree — warning never fires | Pages Router only; App Router has no _document |
Decision: ThemeScript remains unchanged. The inline blocking script is the
correct pattern for flash-free SSR theme initialisation in Next.js App Router
with static export support. The React 19 dev warning is an accepted, known
side-effect of this approach.
Theme not persisting in incognito
localStorage is unavailable in some private-browsing contexts. The provider
falls back gracefully to defaultMode.
useTheme throws outside provider
useTheme must be called inside a component that is a descendant of
<ThemeProvider>. If you call it at the top level of a layout before the
provider is mounted, wrap it in a client component that lives below the
provider.
Exports
// Components
export { ThemeProvider } from "@maholan/theme"; // "use client" runtime context
export { ThemeScript } from "@maholan/theme"; // Server Component — SSR flash prevention
// Hook
export { useTheme } from "@maholan/theme"; // Read theme context in client components
// Types
export type {
ThemeMode,
ThemeContextValue,
ThemeProviderProps,
ThemeScriptProps,
} from "@maholan/theme";
// Build-time CSS generation (advanced)
export { generateGlobalCss, generateCssVarsForMode } from "@maholan/theme";
export { hslToHex, isHslFormat } from "@maholan/theme";Related Packages
- @maholan/tokens — Design tokens and Tailwind preset
- @maholan/ui — UI components
License
MIT
