Dynamic TailwindCSS Colors in Next.js for White Label Applications
When building a white-label application, branding flexibility is key. This includes dynamically changing the logo, favicon, fonts, and — most importantly — colors.
In this post, we’ll focus only on the TailwindCSS + Next.js side of dynamic color theming. My stack also uses MUI, but we’ll save that for another day.
Approach
Jump to section titled: ApproachWith TailwindCSS 4.0, the config file is now merged into your global.css
, making customization easier using the @theme
directive.
We’ll define CSS variables for our colors, map them to Tailwind utilities, and then update them dynamically at runtime.
1. Define Color Variables
Jump to section titled: 1. Define Color VariablesWe’ll start by defining our color scheme inside :root
.
This example uses five main colors: primary
, success
, info
, warning
, and danger
, each with shades from 100
to 900
.
:root {
--primary-100: #eef5ff;
--primary-200: #cee1ff;
--primary-300: #a2c7ff;
--primary-400: #61a0ff;
--primary-500: #2b7fff;
--primary-600: #005be6;
--primary-700: #0040a2;
--primary-800: #002a6b;
--primary-900: #001a42;
--success-100: #d1ffe1;
--success-200: #b1fccc;
--success-300: #8cf3b0;
--success-400: #59e088;
--success-500: #2dd267;
--success-600: #21b153;
--success-700: #10933e;
--success-800: #05792e;
--success-900: #006323;
/* info, warning, danger... */
}
2. Map Variables to Tailwind Utilities
Jump to section titled: 2. Map Variables to Tailwind UtilitiesIn TailwindCSS 4.0, you define theme variables inside @theme
.
We map each shade to Tailwind’s --color-*
format so we can use classes like bg-primary-500
or text-danger-700
.
@theme {
--color-primary-100: var(--primary-100);
--color-primary-200: var(--primary-200);
/* ...repeat for all colors... */
}
3. Create a Theme Context
Jump to section titled: 3. Create a Theme ContextWe’ll use React’s Context API to store and update the color palette dynamically.
Types:
// color.ts
export type EvaColor = {
primary: string[];
danger: string[];
info: string[];
success: string[];
warning: string[];
};
Context:
// color-context.ts
import { createContext } from "react";
import { EvaColor } from "./color";
export const ColorContext = createContext<{
currentColor: EvaColor;
setPrimaryColor: (color: string) => Promise<void>;
error: string | null;
clearError: () => void;
isLoading: boolean;
}>({
currentColor: { primary: [], danger: [], info: [], success: [], warning: [] },
setPrimaryColor: async () => {},
error: null,
clearError: () => {},
isLoading: false,
});
4. Implement the Provider
Jump to section titled: 4. Implement the Provider// ThemeProvider.tsx
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import { EvaColor } from "./color";
import { ColorContext } from "./color-context";
import { toast } from "sonner";
interface ErrorResponse { error: string; message: string; }
const isErrorResponse = (d: unknown): d is ErrorResponse =>
typeof d === "object" && d !== null && "error" in d && "message" in d;
const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
const [currentColor, setCurrentColor] = useState<EvaColor>({
primary: [], danger: [], info: [], success: [], warning: [],
});
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const setPrimaryColor = useCallback(async (color: string) => {
setIsLoading(true);
setError(null);
try {
const res = await fetch(`/api/get-color?theme=${color}`);
if (!res.ok) {
const errorData = await res.json();
if (isErrorResponse(errorData)) {
toast.error(errorData.message);
return;
}
throw new Error(`HTTP error! status: ${res.status}`);
}
const data = await res.json();
if (!("primary" in data)) throw new Error("Invalid response format");
const colorData: EvaColor = data;
setCurrentColor(colorData);
Object.entries(colorData).forEach(([type, shades]) =>
shades.forEach((c, i) =>
document.documentElement.style.setProperty(`--${type}-${(i + 1) * 100}`, c)
)
);
localStorage.setItem("color", JSON.stringify(colorData));
} catch (e) {
setError(e instanceof Error ? e.message : "Unknown error");
console.error("Error setting primary color:", e);
} finally {
setIsLoading(false);
}
}, []);
const clearError = useCallback(() => setError(null), []);
useEffect(() => {
const stored = localStorage.getItem("color");
if (stored) {
try {
const colors: EvaColor = JSON.parse(stored);
setCurrentColor(colors);
Object.entries(colors).forEach(([type, shades]) =>
shades.forEach((c, i) =>
document.documentElement.style.setProperty(`--${type}-${(i + 1) * 100}`, c)
)
);
} catch {
localStorage.removeItem("color");
}
}
}, []);
const value = useMemo(
() => ({ currentColor, setPrimaryColor, error, clearError, isLoading }),
[currentColor, setPrimaryColor, error, clearError, isLoading]
);
return <ColorContext.Provider value={value}>{children}</ColorContext.Provider>;
};
export default ThemeProvider;
5. Hook for Easy Access
Jump to section titled: 5. Hook for Easy Access// use-color-context.tsx
"use client";
import { useContext } from "react";
import { ColorContext } from "./color-context";
export const useColorContext = () => {
const ctx = useContext(ColorContext);
if (!ctx) throw new Error("useColorContext must be used within a ThemeProvider");
return ctx;
};
6. Wrap Your App
Jump to section titled: 6. Wrap Your App// layout.tsx
import ThemeProvider from "./ThemeProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
7. Color Generator API
Jump to section titled: 7. Color Generator APIIn my setup, I use a custom color generator API:
const res = await fetch(
`https://dynamic-color-generate.heterl0.live/api/get-color?theme=${color}`
);
For production-grade results, you might consider:
-
Your own algorithm for generating color shades
Conclusion
Jump to section titled: ConclusionWith this approach, you can dynamically change TailwindCSS colors in your Next.js app at runtime — perfect for white-label solutions.
Check out my tool here:
Dynamic Color Generate