TailwindCSS Dark Mode in Next.js with Tailwind Typography Prose Classes

author
Joel Hooks
When you release a modern website one thing is clear... users expect dark mode out of the box. They aren't interested in your excuses. They don't care about the time it will take to implement, they just want dark mode. Now.

What you will learn about in this article.

This article is going to explain in clear steps how to add TailwindCSS native dark mode to a Next.js site, including the TailwindCSS Typography plugins prose classes.

There is an assumption that you have a working knowledge of both TailwindCSS and Next.js and a site that you'd like to implement a toggle between a dark and a light theme.

To do this, you will use:

  • Next.js: A React "meta-framework"
  • TailwindCSS: A utility-class system for styling web applications
  • TailwindCSS Typography: A plugin that provides a set of prose classes that provide consistently nice looking typographic defaults (useful for Markdown files, for instance)
  • next-themes: React Hooks based utility library for Next.js that let's you switch themes in your application.

Motivation for dark mode

With a recent relaunch of egghead.io there were daily requests for a "dark mode" for the website. In the past our site had had a default singular dark theme, meaning a theme where the background is dark, and the text is light. The new site presented a solid white–incredibly bright–theme that wasn't very pleasant for many users viewing experience.

Bright themes are particularly aggravating when you are working in a dark room, and some users have vision troubles that are exacerbated by light or dark themes. This means that the ability to choose between one or the other is often critical for some users' ability to use the site at all.

Getting Started

If you don't have a Next.js + TailwindCSS site to work from, here's a github branch from my Next.js Tailwind Starter that is "pre-dark mode" that you can use.

From this point we need to update some configuration files.

The Tailwind Config

tailwind.config.js is in the root directory of the project and provides TailwindCSS the information it needs to correctly run in your environment. The TailwindCSS team has done a great job giving us sensible defaults, but almost every project will have specific needs and requirements that require custom configuration.

module.exports = {
purge: ['./src/**/*.tsx'],
theme: {
typography: (theme) => ({}),
extend: {},
},
variants: {},
plugins: [require('@tailwindcss/typography')],
}

This config is almost as basic as it can be. Since you are using the TailwindCSS Typography plugin, this config let's TailwindCSS know that you want to use it. The config also has a purge property that provides an array o globs letting TailwindCSS know which files it should analyze for purging extra classes not used in your application. If we didn't configure purging, the result would be every single class TailwindCSS has to offer being shipped with our application.

That might not be the end of the world, but it is a lot of extra bundle size that your users will never actually need.

So we purge.

After the purge configuration the see the theme, variants, and plugins. Right now these sections are sparse, but that's about to change.

Enabling Dark Mode in TailwindCSS

Enabling dark mode in TailwindCSS is effectively the flip of a switch:

module.exports = {
darkMode: 'class',
purge: ['./src/**/*.tsx'],
theme: {
typography: (theme) => ({}),
extend: {},
},
variants: {},
plugins: [require('@tailwindcss/typography')],
}

By adding darkmode: 'class' to the config, you've instructed TailwindCSS to include all of the CSS utility classes for dark mode. This enables a dark variant that you can now add as classes to your React elements like className="bg-white dark:bg-gray-900" and the correct class will be provided when dark is active on your html element.

To test out dark mode in the Next.js app, you'll need to make a couple of changes to the /src/_document.tsx source file that is used to provide custom document structure to the Next.js application.

<Html className="dark">
<body className="dark:bg-gray-800">
<Main />
<NextScript />
</body>
</Html>

First we add the dark class to the Html element. This enables the dark mode for the entire application. Then we add dark:bg-gray-800 to the body element to provide a dark background for the Next'js application when it is in dark mode.

yarn dev will run the application, and you should see a dark background. Delete dark from the Html elements className and your app should refresh with a default white background.

We've achieved dark mode! 🌑

Obviously your users aren't going to change source code to enabled toggling, so the next step is to add a button that will toggle the dark mode on and off.

Creating a theme with next-themes and React Hooks

Technically your app is going to have two themes: light and dark

Potentially your app could have many themes up to and including hot dog stand. That's amazing if you want to provide your users with that level of flexibility! lol

There are several relatively complicated ways you might approach the problem of toggling themes. As with many things in the React.js and Next.js world, somebody else has already solved the problem very well, and for this the community favorite is next-themes which promises (and subsequently delivers) a "perfect dark mode in two lines of code".

Yes please.

yarn add next-themes

Open /src/_app.tsx

function MyApp({Component, pageProps}: AppProps) {
return (
<>
<DefaultSeo {...SEO} />
<Component {...pageProps} />
</>
)
}

Now, in /src/_app.js import the ThemeProvider and wrap your application Component with it:

import {ThemeProvider} from 'next-themes'
function MyApp({Component, pageProps}: AppProps) {
return (
<>
<DefaultSeo {...SEO} />
<ThemeProvider>
<Component {...pageProps} />
</ThemeProvider>
</>
)
}

So far, nothing has really changed in the app. Since dark is hard-coded in your _app.tsx and there is no mechanism to toggle the mode, your application is stuck in dark mode.

Go ahead and delete the className from the Html element:

<Html>
<body className="dark:bg-gray-800">
<Main />
<NextScript />
</body>
</Html>

Your application will reload, and will once again have the default white background that got us into this situation in the first place.

Toggling between light and dark modes with just a click

Open /src/pages/index.tsx:

export default function Home() {
return (
<div>
<h1 className="text-3xl text-pink-500" css={{backgroundColor: 'teal'}}>
Welcome to Your App
</h1>
</div>
)
}

This is relatively simple React page component that is located at the root of the site. It defines a div as a container and an h1 element with a bit of welcome text and some questionably stylish classes applied.

To make the toggle work, we need to import a hook from next-themes, manage a little piece of state, and wire it all together in a button.

First, import the useTheme hook:

import {useTheme} from 'next-themes'
export default function Home() {
return (
<div>
<h1 className="text-3xl text-pink-500" css={{backgroundColor: 'teal'}}>
Welcome to Your App
</h1>
</div>
)
}

Now call the useTheme hook to gain access to theme and setTheme.

import {useTheme} from 'next-themes'
export default function Home() {
const {theme, setTheme} = useTheme()
return (
<div>
<h1 className="text-3xl text-pink-500" css={{backgroundColor: 'teal'}}>
Welcome to Your App
</h1>
</div>
)
}

Now, add a button element with an onClick handler to use as a toggle:

import {useTheme} from 'next-themes'
export default function Home() {
const {theme, setTheme} = useTheme()
return (
<div>
<h1 className="text-3xl text-pink-500" css={{backgroundColor: 'teal'}}>
Welcome to Your App
</h1>
<button onClick={}>toggle</button>
</div>
)
}

To toggle, we want to check and see what the current theme is, and set the appropriate theme based on that:

import {useTheme} from 'next-themes'
export default function Home() {
const {theme, setTheme} = useTheme()
return (
<div>
<h1 className="text-3xl text-pink-500" css={{backgroundColor: 'teal'}}>
Welcome to Your App
</h1>
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
toggle
</button>
</div>
)
}

A couple of things to note are:

  1. The button is completely unstyled and doesn't really look like a button
  2. clicking it does absolutely nothing 😭

The first issue just means you need to use tailwind to make the button look awesome, but the second issue is more concerning and you need to address that to get this toggle working at all. It's a multi-faceted problem resulting from how we've configured dark mode.

In the tailwind.config.js you set darkMode with the class option. This configuration property also has a media option that uses the prefers-color-scheme media of modern browsers and operating systems to look at how the user has configured their system. The class option, however, means we can select and toggle the mode. In fact, you could delete the button, set the darkMode config to media and call it a day.

For many use cases the class config is the most flexible and is preferred.

In /src/_app.js you need to tell the ThemeProvider to use the class attribute:

<ThemeProvider attribute="class">
<Component {...pageProps} />
</ThemeProvider>

Let your app compile, refresh the page, and start toggling. Back and forth. Dazzling. A fully configured dark mode powered by Tailwind CSS in a Next.js app.

The future is now.

Solving some problems with our TailwindCSS config and dark mode

This is great. It works!

There are still a couple of problems to solve:

  1. Build times are slooooooow (on large projects they can also completely run out of memory)
  2. If you visit /hi - an mdx file rendered and presented with TailwindCSS Typography prose class, you notice that the text is black.

Slow builds with TailwindCSS Dark Mode and Next.js

This is a known problem that is at the core a webpack issue and both the Next.js team and the TailwindCSS team are aware of it. Basically, TailwindCSS + Dark Mode is a massive CSS file, and webpack hates building source maps for massive CSS files.

👋 If you know how to solve this please hit me up on Twitter

For our application this is a huge hassle and requires that we run the development environment with additional memory allocated to node:

NODE_OPTIONS=--max-old-space-size=4048 yarn dev

Ultimately it's a small price to pay for dark mode, and will eventually be fixed upstream. It was also alleviated a bit for us by turning on purging for the dev environment in tailwind.config.css

module.exports = {
darkMode: 'class',
purge: {
enabled: true,
content: ['./src/**/*.tsx'],
},
theme: {
typography: (theme) => ({}),
extend: {},
},
variants: {},
plugins: [require('@tailwindcss/typography')],
}

These options require purge to be an object instead of an array. We set enabled: true and content: ['./src/**/*.tsx'] which is the same array as we had previously set purge to.

Purging CSS means that TailwindCSS tries it best to analyze the source that you've pointed to in content and not remove any CSS classes that you've used.

You can test it now by running the following commands:

yarn build
yarn start

Controlling the Purge

If all is well, your app should function as expected. If toggling dark mode doesn't work or appear to do anything, it could mean that the dark CSS class variants have been stripped from your application because the dark class isn't assigned to a className by default.

In this example, that doesn't appear to be happened, but if you encounter this in your application where it works in development, but not in production you might need to add a safelist property to your tailwind.config.js purge options:

module.exports = {
darkMode: 'class',
purge: {
enabled: true,
content: ['./src/**/*.tsx'],
options: {
safelist: ['dark'], //specific classes
},
},
theme: {
typography: (theme) => ({}),
extend: {},
},
variants: {},
plugins: [require('@tailwindcss/typography')],
}

The safelist allows you to specify classes that TailwindCSS will always keep around for you and not purge. At the time of this writing the only documentation for this is buried in some Github issue comments.

Dark Mode for TailwindCSS Typography Prose Classes

By default TailwindCSS Typography doesn't support dark mode. Prose classes are also notoriously challenging to customize. You can't just set a className instead you need to override defaults in your tailwind.config.js:

module.exports = {
//...
theme: {
extend: {
typography: (theme) => ({
dark: {
css: {
color: 'white',
},
},
}),
},
},
//...
}

In the theme section of the config you a typography property under extend which allows us to extend the @tailwindcss/typography plugin. The configuration property takes a function that passes in the theme and returns an object that extends the theme for that plugin.

It makes me a little dizzy to think about, but the extension we return adds a dark property with a css property that sets color: 'white'

Now, in /src/layouts/index.tsx on line 28 you'll find the prose class being applied to a div. This file is the default layout that mdx files use in your application.

<div className="prose md:prose-xl max-w-screen-md">
{title && <h1 className="text-xl leading-tight">{title}</h1>}
{children}
</div>

Now add dark:prose-dark and dark:md:prose-xl-dark to the className of the div:

<div className="prose md:prose-xl dark:prose-dark dark:md:prose-xl-dark">
{title && <h1 className="text-xl leading-tight">{title}</h1>}
{children}
</div>

Refresh...

Nothing happens. No changes. There's another step in the tailwind.config.js in the variants config add typography: ['dark']:

module.exports = {
darkMode: 'class',
purge: {
enabled: true,
content: ['./src/**/*.tsx'],
options: {
safelist: ['dark'], //specific classes
},
},
theme: {
typography: (theme) => ({}),
extend: {
typography: (theme) => ({
dark: {
css: {
color: 'white',
},
},
}),
},
},
variants: {
typography: ['dark'],
},
plugins: [require('@tailwindcss/typography')],
}

Voíla! You should see the body text of http://localhost:3000/hi become white as configured.

There are a lot of options for customizing the look and feel of your markdown. If you want some inspiration, Lee Rob has done a wonderful job for his personal site and you can check out the config here.

Summary

Users want dark mode and to set it up with TailwindCSS and Next.js it requires some configuration and basic state management. What you've done so far is just a start, and there is a lot of room to expand on the styles to make your application shine.

If you'd like to look more closely at a larger scale full-featured application (the one you are looking at right now in fact), you can checkout the repository for the egghead website on Github.

Here's the end state of the project you've been working on in this article on Github as well.

If you've got any questions, please ask them on Twitter!