Adding themes to your Next.js app makes it possible for your users to personalize their experience and improve the usability of your product. With themes, users can easily switch between light and dark modes, or change the color scheme of the app, depending on their preference. In this tutorial, I will show you how to set up dark mode and add other themes to your Next.js app using the next-theme library, TailwindCSS, and CSS variables. So, let's get started!
Getting started
Next.js + TailwindCSS
You can follow the Install Tailwind CSS with Next.js guide.
Next-themes
This package will do the “magic”, it provides a custom useTheme
hook to consume and change the current theme. It can also select between dark and light modes based on the user’s system-preferred color theme. To install it, all you need to do is run the following command in your project's directory:
npm install next-themes
# or
yarn add next-themes
Setting up dark mode
The most common use of the next-themes library is setting up dark mode. Later, we will explore how to add more themes to your app.
- Wrap
<Component />
with<ThemeProvider />
exported from next-themes:
// pages/_app.tsx
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { ThemeProvider } from "next-themes";
export default function App({ Component, pageProps }: AppProps) {
return (
<ThemeProvider attribute="class">
<Component {...pageProps} />
</ThemeProvider>
);
}
- Add
’class’
todarkMode
option in the tailwind.config.js:
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
};
We’re opting out of letting the operating system automatically modify the app’s theme, which is set via darkMode: “media“
, in favor of toggling dark mode manually.
- We’re now able to use the
useTheme
hook provided by the library to change the current theme:
// pages/index.tsx
import { useTheme } from "next-themes";
export default function Home() {
const { resolvedTheme, setTheme } = useTheme();
return (
<div className="py-0 px-8">
<div className="flex flex-col justify-center px-8 w-full">
<nav className="relative flex items-center justify-between w-full max-w-2xl pt-8 pb-8 mx-auto text-gray-900 border-gray-200 dark:border-gray-700 sm:pb-16">
<span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
Current theme: {resolvedTheme}
</span>
<ToggleButton
onClick={() =>
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
selectedTheme={resolvedTheme}
/>
</nav>
</div>
</div>
);
}
- Fix hydration errors
If you now try to yarn dev
your app you’ll get a Hydration error:
In order for hydration to function correctly, the initial client-side rendered version of the UI must be identical to the server-side one. However, in this case, the resolvedTheme
variable is undefined
on the server and a string
on the client, causing the React trees rendered on the server and client to be different. As a result, hydration fails.
To fix this we need to ensure we don’t consume the resolvedTheme
variable in the first component render:
// pages/index.tsx
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export default function Home() {
const [mounted, setMounted] = useState(false);
const { resolvedTheme, setTheme } = useTheme();
useEffect(() => setMounted(true));
return (
<div className="py-0 px-8">
<div className="flex flex-col justify-center px-8 w-full">
<nav className="relative flex items-center justify-between w-full max-w-2xl pt-8 pb-8 mx-auto text-gray-900 border-gray-200 dark:border-gray-700 sm:pb-16">
<span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
Current theme: {mounted && resolvedTheme}
</span>
<ToggleButton
onClick={() =>
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
selectedTheme={resolvedTheme}
/>
</nav>
</div>
</div>
);
}
In this case, we are using the value of the mounted
state to ensure that our component has been rendered client side at least once before attempting to access the resolvedTheme
variable. At this moment the React trees on both client and server look like this:
// server
<span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
Current theme: undefined
</span>
// client
<span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
Current theme: undefined
</span>
Rather than this, which was what cause the error :
// server
<span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
Current theme: undefined
</span>
// client
<span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
Current theme: string
</span>
By doing this, we can ensure that hydration works properly because the React trees are identical.
- Styling components with the
dark:
prefix
As you’ve seen in previous code examples, you can now use the dark:
prefix with any Tailwind class to specify a variant that will only be applied when dark mode is active. For example, in the code below, the background color of a card grid will change based on the current theme:
const ProductsGrid = () => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 ">
{products.map((product) => (
<div
key={product.id}
className="bg-white border hover:shadow-sm transition-all cursor-pointer border-gray-50 rounded-lg p-6 dark:bg-gray-800 dark:border-gray-600"
>
...
</div>
))}
</div>
);
};
Setting up extra themes
In order to add multiple themes to our Next.js app, you need to provide a list of options. Each theme option must map to a CSS attribute selector that updates global CSS variables, which in turn should update the values of custom Tailwind classes. This leads to changes in the colors of the custom classes used throughout your app.
You need to manipulate three files, _app.tsx, globals.css, and tailwind.config.js
- In the _app.tsx file, remove
attribute=’class’
as we are no longer using tailwindCSS's built-in dark mode:
// _app.tsx
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { ThemeProvider } from "next-themes";
export default function App({ Component, pageProps }: AppProps) {
return (
<ThemeProvider>
<Component {...pageProps} />
</ThemeProvider>
);
}
After making the change, instead of the html
element having a class
attribute with a value of "dark" or "light", it will have a data-theme
attribute with a value of "dark" or "any theme" (or any other value you have chosen for your themes).
- In the globals.css file, we will set up attribute selectors for each theme option. This will allow all of the global CSS variables to update automatically when the
useTheme
function updates thedata-theme
attribute. For example:
/* globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
[data-theme="dark"] {
/* dark theme styles go here */
}
[data-theme="gruvbox"] {
/* gruvbox theme styles go here */
}
[data-theme="pink"] {
/* pink theme styles go here */
}
- In the tailwind.config.js file, we will set
darkMode
tofalse
and define custom classes that use the CSS variables we just defined:
// tailwind.config.js
module.exports = {
darkMode: false,
theme: {
extend: {
/* custom classes go here */
},
},
variants: {},
plugins: [],
};
By setting darkMode
to false
, we are disabling tailwindCSS's built-in dark mode. You can then define custom classes in the theme.extend
object that use the CSS variables we defined in the globals.css file to control the styling of our elements.
- Now you need to provide the user with a way to change the current theme:
// pages/index.tsx
const themes = [
{ name: "Light" },
{ name: "Dark" },
{ name: "Monakai" },
{ name: "Pink" },
];
const ToggleButton = () => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
// When mounted on client, now we can show the UI
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<>
<span className="p-1 sm:px-3 sm:py-2 text-th-secodary">
Current theme: {mounted && theme}
</span>
<div>
<label htmlFor="theme-select" className="sr-only mr-2">
Choose theme:
</label>
<select
name="theme"
id="theme-select"
className="bg-white text-gray-800 border-gray-800 border py-1 px-3"
onChange={(e) => setTheme(e.currentTarget.value)}
value={theme}
>
<option value="system">System</option>
{themes.map((t) => (
<option key={t.name.toLowerCase()} value={t.name.toLowerCase()}>
{t.name}
</option>
))}
</select>
</div>
</>
);
};
- That’s it! Now you can start applying your new custom classes that change their color values based on the current theme:
// pages/index.tsx
const ProductsGrid = () => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 ">
{generateProducts().map((product) => (
<div
key={product.id}
className="bg-white border hover:shadow-sm transition-all cursor-pointer border-gray-50 rounded-lg p-6 dark:bg-gray-800 dark:border-gray-600"
>
<div className="relative w-full h-80">
<Image
src={product.imageUrl}
alt={product.title}
placeholder="blur"
blurDataURL={`data:image/svg+xml;base64,${toBase64(
shimmer(700, 475)
)}`}
fill
sizes="100vw"
className="object-cover"
/>
</div>
<h2 className="font-bold text-xl mt-2 text-gray-800">
{product.title}
</h2>
<p className="text-th-accent text-sm">{product.subtitle}</p>
<p className="text-gray-700 dark:text-gray-300 mt-2">
{product.description}
</p>
<div className="flex space-x-2 mt-4">
<button className="py-2 px-4 rounded-full bg-th-accent font-bold dark:bg-blue-800">
Buy
</button>
<button className="p-1 h-9 w-9 flex items-center justify-center rounded-full bg-gray-200 text-gray-700 font-bold ">
<FiShoppingCart />
</button>
</div>
</div>
))}
</div>
);
};
End result
Conclusion
Creating a custom dark mode and multiple themes is a great way to give users a more personalized and tailored experience. With the help of Tailwind's built-in dark mode, CSS variables, and custom classes, it's easy to create a dynamic, lightweight theme system that changes colors and styles on the fly. This article provided a step-by-step tutorial on how to create such a system, as well as a link to the repository that contains all the code used for the provided examples.
Top comments (1)
Hey Luis, nice post! 👏 Thanks for sharing that
I got curious, though, about the mentioned (but not presented)
tailwind.config.js
theme extend custom classes.Would one be able to configure something similar to tailwind's
dark:
modification directive for each defined theme, for example?