@maholan/theme
v0.2.1
Published
Theme provider and dark mode management for the MHL Untitled UI Platform.
Downloads
235
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>.
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
