@abdelamrah/theme-engine
v1.0.0
Published
A production-ready frontend theme engine for React / Next.js with runtime theme switching, CSS variables integration, and shadcn/ui + Tailwind CSS compatibility.
Maintainers
Readme
@abdelamrah/theme-engine
A production-ready frontend theme engine for React / Next.js applications.
Runtime theme switching · CSS variables · shadcn/ui + Tailwind compatible · localStorage persistence · Zero backend dependencies
Features
- Define themes with colors, radius, typography, and shadow tokens
- Switch themes at runtime with instant CSS variable updates
- Add / remove custom themes dynamically through a client-side API
- Persist themes in
localStorageacross page reloads - React hook + context provider with optimised re-renders
- Four built-in presets:
modern,minimal,glass,dark - Full TypeScript, strict mode
- ESM + CJS dual build
- Compatible with Next.js App Router, shadcn/ui, and Tailwind CSS
Installation
npm install @abdelamrah/theme-engine
# or
pnpm add @abdelamrah/theme-engine
# or
yarn add @abdelamrah/theme-enginePeer dependencies: react ^18 || ^19, react-dom ^18 || ^19
Quick start
1. Wrap your app with <ThemeProvider>
// app/layout.tsx (Next.js App Router)
import { ThemeProvider } from "@abdelamrah/theme-engine";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ThemeProvider defaultTheme="modern">
{children}
</ThemeProvider>
</body>
</html>
);
}2. Use the hook in any client component
"use client";
import { useThemeEngine } from "@abdelamrah/theme-engine";
export function ThemeSwitcher() {
const { themes, activeTheme, setActiveTheme } = useThemeEngine();
return (
<select
value={activeTheme?.name ?? ""}
onChange={(e) => setActiveTheme(e.target.value)}
>
{themes.map((t) => (
<option key={t.name} value={t.name}>
{t.label ?? t.name}
</option>
))}
</select>
);
}3. Use CSS variables in Tailwind
In your tailwind.config.ts extend the theme to consume the variables:
// tailwind.config.ts
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
primary: {
DEFAULT: "var(--primary)",
foreground: "var(--primary-foreground)",
},
secondary: {
DEFAULT: "var(--secondary)",
foreground: "var(--secondary-foreground)",
},
muted: {
DEFAULT: "var(--muted)",
foreground: "var(--muted-foreground)",
},
accent: { DEFAULT: "var(--accent)" },
border: "var(--border)",
destructive: "var(--destructive)",
ring: "var(--ring)",
},
borderRadius: {
DEFAULT: "var(--radius)",
sm: "var(--radius-sm)",
md: "var(--radius-md)",
lg: "var(--radius-lg)",
xl: "var(--radius-xl)",
},
fontFamily: {
sans: ["var(--font-sans)"],
mono: ["var(--font-mono)"],
},
boxShadow: {
theme: "var(--shadow)",
},
},
},
} satisfies Config;API Reference
<ThemeProvider>
| Prop | Type | Default | Description |
|---|---|---|---|
| initialThemes | ThemeConfig[] | presets | Base list; merged with the four built‑in presets and localStorage overrides |
| defaultTheme | string | first preset | Bootstrap theme when no active theme is persisted yet; a value in localStorage wins after hydration |
| persistThemes | boolean | true | Save custom themes to localStorage |
| applyOnChange | boolean | true | Update CSS vars when active theme changes |
| backendAdapter | ThemeBackendAdapter | undefined | Adapter used to fetch global theme config from your backend |
| backendSync | ThemeSyncOptions & { enabled?: boolean } | undefined | Backend sync options (polling, merge/replace mode, callbacks) |
useThemeEngine()
Returns:
| Field | Type | Description |
|---|---|---|
| themes | ThemeConfig[] | All registered themes |
| activeTheme | ThemeConfig \| null | Currently applied theme |
| setActiveTheme | (name: string) => void | Switch + apply theme by name |
| api | IThemeAPI | Direct access to ThemeAPI |
ThemeAPI
The singleton client-side state API. Can be used outside React components.
import { ThemeAPI } from "@abdelamrah/theme-engine";
// Add a custom theme
ThemeAPI.addTheme({
name: "ocean",
label: "Ocean",
colors: {
background: "#0a1628",
foreground: "#e2e8f0",
primary: "#38bdf8",
primaryForeground: "#0a1628",
secondary: "#1e3a5f",
secondaryForeground: "#bae6fd",
border: "#1e3a5f",
},
});
// Subscribe to changes
const unsubscribe = ThemeAPI.subscribe((themes) => {
console.log("themes updated:", [...themes.keys()]);
});
// Remove theme
ThemeAPI.removeTheme("ocean");
// Clean up
unsubscribe();Export / import (JSON)
const json = ThemeAPI.exportThemesToJSON(true);
const result = ThemeAPI.importThemesFromJSON(json, "merge"); // or "replace"
if (result.ok) console.log(`Imported ${result.count} themes`);
// Dynamic load from a URL (browser; CORS must allow the origin)
const fromUrl = await ThemeAPI.importThemesFromURL("/api/themes.json", "merge");Standalone helpers (no store mutation): serializeThemesToJSON, parseThemesFromJSON, downloadThemesJsonFile from @abdelamrah/theme-engine.
Backend-agnostic global sync
Use a ThemeBackendAdapter to fetch remote theme config from any backend:
import {
startThemeSync,
syncThemesFromBackend,
type ThemeBackendAdapter,
} from "@abdelamrah/theme-engine";
const adapter: ThemeBackendAdapter = {
async fetchConfig() {
const res = await fetch("/api/theme-config", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return {
version: "v42",
themes: [],
activeThemeName: "modern",
mode: "merge", // or "replace"
};
},
};
// one-shot
await syncThemesFromBackend(adapter, { mode: "merge" });
// polling
const controller = startThemeSync(adapter, { intervalMs: 30000 });
// later
controller.stop();By default, backend sync does not override a user’s persisted active theme (preferLocalActiveTheme defaults to true). Return forceActiveTheme: true from fetchConfig() (or set preferLocalActiveTheme: false in sync options) when you need a fleet-wide forced theme.
Theme Studio → all users: pass onPublishGlobal on <ThemeStudio> to PUT the current registry + active theme to your API; clients must still load it via backendAdapter polling. The publish payload sets forceActiveTheme: true so the rollout applies everywhere.
You can also use <ThemeStudio publishGlobalEndpoint="/api/theme-config" /> for a no-code callback setup (automatic JSON PUT).
applyTheme(theme)
Imperatively applies CSS variables from a ThemeConfig to document.documentElement. Handles the dark class on <html> automatically.
import { applyTheme } from "@abdelamrah/theme-engine";
import { dark } from "@abdelamrah/theme-engine";
applyTheme(dark);Built-in presets
import { presets, modern, minimal, glass, dark } from "@abdelamrah/theme-engine";| Name | Style | Dark |
|---|---|---|
| modern | Clean blue-accent | No |
| minimal | High-contrast monochrome | No |
| glass | Frosted glass + blur | No |
| dark | Deep dark with indigo accent | Yes |
ThemeConfig type
interface ThemeConfig {
name: string; // unique identifier
label?: string; // display name
dark?: boolean; // toggles Tailwind `dark` class on <html>
colors: {
background: string;
foreground: string;
primary: string;
primaryForeground?: string;
secondary: string;
secondaryForeground?: string;
muted?: string;
mutedForeground?: string;
accent?: string;
border: string;
destructive?: string;
ring?: string;
};
radius?: string | {
base: string;
sm?: string; md?: string; lg?: string; xl?: string;
};
typography?: {
fontSans?: string;
fontMono?: string;
fontDisplay?: string;
};
effects?: {
shadow?: string;
borderWidth?: string;
backdropBlur?: string;
};
}License
MIT © Abdel
