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 classes to scope the CSS variables: the app knows what set of variables is effective based on the class applied on the root <html> element.
The main idea here is to scope the definition of variables to a className instead of applying them to the root.
Then, apply the className corresponding to your active theme on the html element:
@import "./themes/default";
@import "./themes/dark";You can now simply change the class to change theme. This can be done using document.documentElement.className 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. Client-side storage (like Local Storage) isn't available until JavaScript hydrates, so the page may briefly render with the default theme before the saved preference is applied.
You have multiple options:
| Approach | Trade-offs |
|---|---|
| No persistence | If you don't need to store the theme, the default class can be hardcoded on the <html> and resets on refresh. You won't have any flash. |
| Store in Local Storage | Local Storage is only available after JavaScript hydrates, which means your page will first load once before your client code can fetch the theme and apply it, causing the UI to flash. This approach is simple but only makes sense if you don't mind the performance hit of delaying the render of your page after JS loads (e.g. for SPA behind a login). |
| Store in Cookies + SSR | The theme is stored in the user's browser cookies, and set alongside the request to the server. The server reads the cookie before rendering, so the correct theme is applied from the first byte. No flash of the wrong theme. This approach requires server-side rendering (i.e. won't work with static exports). |
| Store in DB + SSR | If your users are authenticated, you can also read the value in the Database to know which class name to apply. This is relevant for server-side logics where the backend fetches the user information and builds the page to render before sending it to the client. |
Here's a proposed implementation of the theme manager that stores the theme in local storage. The theme is automatically loaded once the client-side code runs.
import { ThemeProviderWithLocalStorage } from "@/ui/themes/ThemeContextWithLocalStorage";
function App() {
return (
<ThemeProviderWithLocalStorage autoLoadTheme={true}>
{/* Your app */}
</ThemeProviderWithLocalStorage>
);
}Here's an example that uses Cookie storage, and server side logic to load the correct default theme on first load.
import { ThemeProviderWithCookies } from "@/ui/themes/ThemeContextWithCookies";
import { cookies } from "next/headers";
const THEME_COOKIE = "devie-theme";
const DEFAULT_THEME = "theme-default";
const ALLOWED_THEMES = new Set([DEFAULT_THEME, "theme-dark", "theme-forest"]);
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
// Read theme from cookie on the server
const cookieStore = await cookies();
const cookieTheme = cookieStore.get(THEME_COOKIE)?.value;
const savedTheme =
cookieTheme && ALLOWED_THEMES.has(cookieTheme) ? cookieTheme : DEFAULT_THEME;
return (
<html lang="en" className={savedTheme}>
<body>
<ThemeProviderWithCookies defaultTheme={savedTheme}>
{children}
</ThemeProviderWithCookies>
</body>
</html>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
defaultTheme | string | "theme-default" | The initial theme class name |
autoLoadTheme | boolean | false | LocalStorage Only: Automatically load saved theme on mount |
| 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 |
isThemeLoaded | boolean | LocalStorage only: whether theme has loaded |
loadInitialTheme() | () => void | LocalStorage only: manually trigger theme loading |
primaryColor | string | Computed primary color from current theme |