When I started on a new project that requires multiple theming support, I've always wanted to make it "smart" in a way so I won't have to repeat the code in multiple places (which makes it hard to maintain).
My main considerations were:
- Single source of truth for all colours in all themes
- Defining a set of colours once, and swapping them when theme changes
- Making it easy to maintain
I've looked around at various sources, trying to find the best practice and in the end, I went with the following setup.
With the following code, I'm able to achieve 2 different shades of grey without using dark:text-[insert-your-colour]
. Basically, when the theme is toggled, a different shade of grey is used depending on whether it's dark
or light
mode. This method also supports additional themes if needed.
// tailwind.config.js
module.exports = {
// ... other config omitted
darkMode: "class",
theme: {
extend: {
colors: {
gray: {
// Dark mode - grays (Not intended to be used directly)
"dark-base": "#0A0A0B",
"dark-alternate": "#111113",
"dark-elevation-100": "#161618",
"dark-elevation-200": "#27272A",
"dark-elevation-300": "#313135",
"dark-100": "#1D1D20",
"dark-200": "#313135",
"dark-300": "#4E4E55",
"dark-400": "#797981",
"dark-500": "#56565D",
"dark-600": "#797981",
// Light mode - grays (Not intended to be used directly)
"light-base": "#FFFFFF",
"light-alternate": "#FAFAFA",
"light-elevation-100": "#FFFFFF",
"light-elevation-200": "#FFFFFF",
"light-elevation-300": "#FFFFFF",
"light-100": "#F5F5F5",
"light-200": "#E4E4E6",
"light-300": "#D2D2D5",
"light-400": "#797981",
"light-500": "#9A9AA2",
"light-600": "#EAEAEB",
// Actual grays to be used
base: "var(--gray-base)",
alternate: "var(--gray-base)",
"elevation-100": "var(--gray-elevation-1)",
"elevation-200": "var(--gray-elevation-2)",
"elevation-300": "var(--gray-elevation-3)",
100: "var(--gray-100)",
200: "var(--gray-200)",
300: "var(--gray-300)",
400: "var(--gray-400)",
500: "var(--gray-500)",
600: "var(--gray-600)",
}
}
}
}
}
For the colours labelled with Not intended to be used directly)
, they are simply used to document all the colours for use later, and serve as a single source of truth.
With the tailwind configuration set, the actual magic happens in your global CSS file.
// Your global.css file
// theme() function is provided by tailwindCSS
// Colours are retrieved from tailwind.config.js we set earlier
@layer base {
:root {
--gray-base: theme("colors.gray.light-base");
--gray-elevation-100: theme("colors.gray.light-elevation-100");
--gray-elevation-200: theme("colors.gray.light-elevation-200");
--gray-elevation-300: theme("colors.gray.light-elevation-300");
--gray-100: theme("colors.gray.light-100");
--gray-200: theme("colors.gray.light-200");
--gray-300: theme("colors.gray.light-300");
--gray-400: theme("colors.gray.light-400");
--gray-500: theme("colors.gray.light-500");
--gray-600: theme("colors.gray.light-600");
}
.dark {
--gray-base: theme("colors.gray.dark-base");
--gray-elevation-100: theme("colors.gray.dark-elevation-100");
--gray-elevation-200: theme("colors.gray.dark-elevation-200");
--gray-elevation-300: theme("colors.gray.dark-elevation-300");
--gray-100: theme("colors.gray.dark-100");
--gray-200: theme("colors.gray.dark-200");
--gray-300: theme("colors.gray.dark-300");
--gray-400: theme("colors.gray.dark-400");
--gray-500: theme("colors.gray.dark-500");
--gray-600: theme("colors.gray.dark-600");
}
}
You could name the themes or add additional themes if you like. For this example, I'm simply using dark
with light
as my default theme.
Then to finally piece everything together, here I'm using React to set the theme by adding the string dark
as part of the className for my app.
// The main app file. In this case, _app.tsx (NextJS)
export const App = ({ Component, pageProps }) => {
const [theme, setTheme] = useState<"dark" | "">("dark");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
// Refer to this line where I add the theme variable
<div className={classNames(theme, "h-full w-full")}>
<Component {...pageProps} />
</div>
</ThemeContext.Provider>
);
};
And with these 3 files set, theming is now possible!
Just to quickly run through how they work:
- We define colour(s) based on different themes. E.g. In this example, my grey palette has 2 sets of colours for light and dark mode. We utilised tailwind config colour palettes for this.
- We used the colours defined in tailwind to apply globally in our CSS file, with each theme "activated" when the class name is present. E.g. I used
:root
for the default theme and.dark
for my dark theme. - In each of these themes, notice how we are using different colours to set to the same CSS variables. E.g. for
:root { --gray-base }
, it usescolours.gray.light-base
while for.dark { --gray-base }
, it usescolours.gray.dark-base
. - Also notice how our tailwind config colours use the same CSS variable to set colours. E.g.
{ gray: { base: 'var(--gray-base)' } }
- When the app has the className
dark
, it'll use the new CSS variable for the same className colour. E.g.text-gray-base
colour will change depending if the theme is light or dark mode.
This way, when we actually develop the application, we won't have to worry about changing the colours when the theme changes. So if we used text-gray-base
for our text in light mode, we won't have to change this text colour to a lighter one for dark mode since the CSS variable will take care of it for us.
When creating a multi-themed application, it is also important to note that design and planning ahead is a must. In my example, I had an excellent designer Saugat (also my good friend!) who helped to create 2 sets of grey that were used in this example.