Jump to main protocol

Beyond Dark Mode

Creating more than one color scheme with Tailwind and Next.js

Last Updated

2022.08.19 20:35:21

Dark mode seems to be all the rage right now, and there are plenty of articles explaining how to achieve this in Next.js already, but what if you want to allow additional color schemes beyond dark and light mode?

And for Tailwind users, how can this work with its dark mode feature?

Tailwind’s Dark Mode

Before we go over how to implement something like this, let's quickly review Tailwinds Dark Mode feature. Tailwind ships a dark: class variant, which leverages the prefers-color-scheme media feature by default, but it can also be toggled manually with a class instead with this tailwind config setting:

//: javascript
module.exports = {
darkMode: 'class',
// ...
}

From Tailwind's dark mode docs:

“Now instead of dark:{class} classes being applied based on prefers-color-scheme, they will be applied whenever dark class is present earlier in the HTML tree.”

This approach will lend itself perfectly to building toggle switches in our Next.js application (more on that later), allowing users to switch between modes rather than solely relying on their preferred color scheme.

But what if we want to offer additional color schemes beyond dark mode?

Extending Tailwind

It’s actually easier than you'd think to add additional class-based color schemes to Tailwind, beyond their default dark mode.

In the tailwind.config.js file this is all we need:

//: javascript
let plugin = require('tailwindcss/plugin')
module.exports = {
darkMode: 'class',
plugins: [
plugin(function ({ addVariant }) {
addVariant('metal', '.metal &'),
}),
],
}

The above custom plugin adds a metal: class variant we can use to target our new color scheme! Just like with the dark: class variant, metal:{class} classes will be applied whenever the metal class is present earlier in the HTML tree.

Custom utility classes

One of the first things you might notice is that once you start incorporating more color modes, adjusting backgrounds and text colors for your components will become a bit cumbersome.

Consider the following example:

//: javascript
import React from 'react'
const Card = () => {
return (
<div className="bg-white dark:bg-black metal:bg-black border border-black dark:border-white metal:border-red">
<h2 className="text-black dark:text-white metal:text-red">linguine</h2>
<p className="text-black text-opacity-50 dark:text-white dark:text-opacity-50 metal:text-red metal:text-opacity-50">the best pasta</p>
</div>
)
}
export default Card

All of that is just to define some colors on a card component!

Now imagine that mixed with a dozen other classes 🥴

And what if you have this same color setup across multiple components and then decide you actually need to change one of the colors defined for a specific color scheme or need to add another color scheme down the line? 😵‍💫

Sounds like hell, right?

Enter: ✨custom utility classes✨

Here's where we can leverage defining our own utility classes to both slim down these class definitions in our components, and make it immensely easier to manage going forward.

Personally, I like to do this in a custom _utilities.css file, instead of inside the tailwind.config.js file. Let's see how that file is setup:

//: css
@tailwind utilities;
@layer utilities {
/* custom utility classes defined here... */
}

In this file, we can start defining custom classes to use in our components, a perfect place to combine our lengthy color classes for all of our different modes. Here's how I like to do it:

//: css
@tailwind utilities;
@layer utilities {
/* Background Colors (combined modes) */
.bg-default {
@apply bg-white dark:bg-black metal:bg-black;
}
/* Border Colors (combined modes) */
.border-default {
@apply border-black dark:border-white metal:border-red;
}
.border-default-50 {
@apply border-black border-opacity-50 dark:border-white dark:border-opacity-50 metal:border-red metal:border-opacity-50;
}
/* Text Colors (combined modes) */
.text-default {
@apply text-black dark:text-white metal:text-red;
}
.text-default-50 {
@apply text-black text-opacity-50 dark:text-white dark:text-opacity-50 metal:text-red metal:text-opacity-50;
}
}

A note on @apply
I know there’s a lot of strong feelings about the use of @apply. But even Adam himself made a great example of when using @apply is acceptable and reasonable in this tweet.

Using the above, let’s see how our fake <Card /> component looks now:

//: javascript
import React from 'react'
const Card = () => {
return (
<div className="bg-default border border-default">
<h2 className="text-default">linguine</h2>
<p className="text-default-50">the best pasta</p>
</div>
)
}
export default Card

Kinda nice right? Our class list is now much easier to scan, and if we need to adjust a color scheme (or add more!) we only have to do it in one place, instead of across every component that has color mode-specific styles!

Try it out in Tailwind Play

Setting Themes in Next.js

Now that we have a clean way to set up different color schemes with our CSS, we need to know how to switch between them on our frontend since we're not relying on the user's system preferences.

To help with this we'll use the next-themes package, which handles persisting the user's selected them, adding/removing class names, and provides a hook to get and set the current theme.

First, we need to set up a custom _app.js file to wrap our app in the <ThemeProvider /> component required by next-themes, but with a few custom configurations in place:

//: javascript
import { ThemeProvider } from 'next-themes'
function MyApp({ Component, pageProps }) {
return (
<ThemeProvider
themes={['dark', 'light', 'metal']}
enableSystem={false}
disableTransitionOnChange
attribute="class"
defaultTheme="dark"
>
<Component {...pageProps} />
</ThemeProvider>
)
}
export default MyApp

Settings Explained

  1. themes={['dark', 'light', 'metal']} (required)
    Light and Dark are included by default, so we need to explicitly define this to include our additional themes.
  2. attribute="class" (required)
    Since our Tailwind config is basing color schemes on the presence of a class earlier in the HTML tree, this must be set to class as well.
  3. defaultTheme="dark" (optional)
    The default theme is normally light, but if you prefer a different default theme for first-time visitors, you can explicitly set it.
  4. disableTransitionOnChange (optional)
    This ensures your UI with different transition durations won't feel inconsistent when changing the theme.
  5. enableSystem={false} (optional)
    Since I have a preferred color scheme for my first-time visitors, I don't want it to switch automatically between light/dark based on the user's system preferences, but you might!

With the above, our application will default to dark mode using the class attribute on the <html> element.

Building a <ThemeSwitcher /> component

Now that we have our themes wired up in both our Tailwind classes and our Next.js application, let's build a component that will display the currently selected theme and allow users to cycle through our available themes.

Here's an interactive example of what we're building. Try it out:

The markup

Luckily, next-themes comes with a handy hook called useTheme() that returns the current theme and a method to change it. Let's set up our component with this and display our current theme:

//: javascript
import React from 'react'
import { useTheme } from 'next-themes'
const ThemeSwitcher = () => {
const { theme, setTheme } = useTheme()
return (
<div>
<p>{theme}</p>
</div>
)
}
export default ThemeSwitcher

Uh-oh! If we include this on our pages as-is, we'll get this error:

Error: Hydration failed because the initial UI does not match what was rendered on the server.

Since the server doesn't know the theme, the value returned from useTheme() will be undefined until mounted on the client, causing the mismatch.

Mounting Client-side

We can fix this by only mounting this component client-side with the help of a handy useHasMounted() hook from the lovely Josh Comeau:

//: javascript
export function useHasMounted() {
const [hasMounted, setHasMounted] = useState(false)
useEffect(() => {
setHasMounted(true)
}, [])
return hasMounted
}

Incorporating that into our component we can fix the hydration errors and see the current value when the page loads:

//: javascript
import React from 'react'
import { useTheme } from 'next-themes'
import { useHasMounted } from '@lib/helpers'
const ThemeSwitcher = () => {
const hasMounted = useHasMounted()
const { theme, setTheme } = useTheme()
// Make sure it's only rendered on the client
if (!hasMounted || !theme) return null
return (
<div>
<p>{theme}</p>
</div>
)
}
export default ThemeSwitcher

Setting a theme

We can also use the setTheme() method to switch to any one of our other defined themes. Let's add a button to handle switching to a specific theme:

//: javascript
import React from 'react'
import { useTheme } from 'next-themes'
import { useHasMounted } from '@lib/helpers'
const ThemeSwitcher = () => {
const hasMounted = useHasMounted()
const { theme, setTheme } = useTheme()
// Make sure it's only rendered on the client
if (!hasMounted || !theme) return null
return (
<div>
<p>{theme}</p>
<button onClick={() => setTheme('metal')}>
Switch to Metal Mode
</button>
</div>
)
}
export default ThemeSwitcher

With the above, clicking our button will switch the current theme to metal: updating the current theme being returned from our useTheme() hook and applying the appropriate class to our HTML tree.

At this point, we've got everything we need to go beyond dark mode 🥳


BONUS: Cycle through themes on click

Rather than manually setting up buttons to switch to specific themes, let's actually make one button that when clicked cycles through our list of themes.

To do this, we'll first define our themes in an array outside of our component. I like to put these in a static data file, but you can also toss this directly in the component file if you'd prefer:

//: javascript
const themes = [
{ title: 'Light Mode', name: 'light' },
{ title: 'Dark Mode', name: 'dark' },
{ title: 'Metal Mode', name: 'metal' },
]

The benefit of this is we can control the order of our themes in the cycle, and extend information about each theme if needed for things like display titles or accessible labels.

By using the theme returned from our hook, we can find the matched object in our new themes array and use it's index to grab the next object (theme) in the array:

//: javascript
import React from 'react'
import { useTheme } from 'next-themes'
import { useHasMounted } from '@lib/helpers'
const themes = [
{ title: 'Light Mode', name: 'light' },
{ title: 'Dark Mode', name: 'dark' },
{ title: 'Metal Mode', name: 'metal' },
]
const ThemeSwitcher = () => {
const hasMounted = useHasMounted()
const { theme, setTheme } = useTheme()
// store our current and next theme objects (will be first theme, if undefined)
const currentIndex = Math.max(0, themes.findIndex((t) => t.name === theme))
const currentTheme = themes[currentIndex]
const nextIndex = (currentIndex + 1) % themes.length
const nextTheme = themes[nextIndex]
// Make sure it's only rendered on the client
if (!hasMounted || !theme) return null
return (
<div>
<p>
Current: <strong>{currentTheme.title}</strong>
</p>
<button onClick={() => setTheme(nextTheme.name)}>
Switch to {nextTheme.title}
</button>
</div>
)
}
export default ThemeSwitcher

How does it loop?
This is because of % themes.length: The remainder operator gives you the remaining value of dividing the first value with the second. So, in our example 2 + 1 % 3 equals zero, bringing us back to our first index in the array.

In Summary

Not everything is black and white, and the same can be said about color schemes! With the help of next-themes and some useful Tailwind configurations, you can offer color schemes beyond dark mode.

Web Developer

Considering projects for Q1 2024

Nick DiMatteo