In Devie UI, supporting multiple themes in one application can be achieved relatively simply by having multiple available sets of CSS Variables to swap between.
Our proposed solution relies on a data attribute to scope the CSS variables: the app knows what set of variables is effective based on the data-devie-theme attribute on the root <html> element.
The main idea here is to scope the definition of variables to a data attribute value instead of applying them to the root.
Then, set the data-devie-theme attribute on the html element to your active theme:
@import "./themes/default";
@import "./themes/dark";You can now simply change the data attribute to change theme. This can be done using document.documentElement.dataset.devieTheme for example, and can be linked to the business logic of your choice as to what action triggers the theme change.
If you want a user's theme choice to survive a page refresh, you will need to store it somewhere and restore it early enough to avoid a flash of the default theme.
The tricky part is timing. If your app is fully prebuilt, the server cannot personalize the initial HTML per request. The best static-friendly solution is to store the theme in Local Storage and run a tiny inline script in layout.tsx before hydration so the correct attribute is applied as early as possible.
Here are the main options and their trade-offs:
| Approach | Trade-offs |
|---|---|
| No persistence | If you don't need to store the theme, the default attribute can be hardcoded on the <html> and resets on refresh. You won't have any flash. |
| Local Storage after hydration | Simple to set up, but the saved theme is only restored after React runs. This can briefly show the default theme first. |
| Local Storage + early boot script | This is what we recommend for static exports. Pages stay prebuilt, the browser restores the attribute before hydration, and theme changes remain fully client-side. |
| Cookies + SSR | Useful when the server truly needs to know the theme before sending HTML, but it turns the page into request-time rendering and prevents a pure static export. |
| DB + SSR | Similar to cookie-based SSR, except the preference is read from your backend. Useful for authenticated apps that already render dynamically on the server. |
This approach keeps the website fully static while still restoring the user's last selected theme before hydration. The layout injects a tiny boot script, and the ThemeContext keeps the data attribute and Local Storage in sync after the app mounts.
import { ThemeProvider } from "@/ui/themes/ThemeContext";
const THEME_STORAGE_KEY = "devie-theme";
const DEFAULT_THEME = "theme-default";
const ALLOWED_THEMES = ["theme-default", "theme-dark"];
const themeBootScript = `(function () {
try {
var allowedThemes = new Set(${JSON.stringify(ALLOWED_THEMES)});
var storedTheme = localStorage.getItem("devie-theme");
document.documentElement.dataset.devieTheme =
allowedThemes.has(storedTheme) ? storedTheme : "theme-default";
} catch {
document.documentElement.dataset.devieTheme = "theme-default";
}
})();`;
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" data-devie-theme={DEFAULT_THEME} suppressHydrationWarning>
<head>
<script id="theme-boot">{themeBootScript}</script>
</head>
<body>
<ThemeProvider defaultTheme={DEFAULT_THEME}>
{children}
</ThemeProvider>
</body>
</html>
);
}If the server needs to know the theme before it renders the page, you will need SSR. That usually makes sense for authenticated apps or other dynamic pages, but it is overkill when the only personalized change is a data attribute on <html>.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultTheme | string | "theme-default" | The initial theme class name |
| Property | Type | Description |
|---|---|---|
selectedTheme | string | The currently active theme |
setTheme(theme) | (string) => void | Set and persist a new theme |
previewTheme(theme) | (string) => void | Preview a theme without persisting |
clearPreviewTheme() | () => void | Clear the preview, revert to selected |
primaryColor | string | Computed primary color from current theme |