Next.js, Tailwind CSS, and Dark Mode
Ah, Dark Mode...
One feature that is increasingly popular and "demanded" in the current generation. One's eyes can be tolerant towards anything that is light themed or bright, but more and more people prefer dark theme as it's more pleasing to the eyes. There is also other aesthetic reasons but mostly the reason revolves around how it's not increasing strain to the eyes.
Having an application that supports both is also a plus point to keeping the users, so it's almost undeniable that it's a necessity in UI design. After all, good UX can at least increase customer's satisfaction.
next-themes
So there is a very good library that is compatible with Next.js in order to implement dark theme, or even another themes. next-themes
provides hooks that can be used to set preferred theme, and persisting it on browser's localStorage
. And you can integrate it with Tailwind CSS to pick up the theme changer based from class names. The basic setup can already be seen on the library's repository, so in this post I'll just point out the next steps.
Meanwhile, my theme switcher would be like this.
"use client";
import { useTheme } from "next-themes";
import { useState, useEffect } from "react";
export default function DarkModeToggle() {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted)
return (
<div className="mx-2">
<button
type="button"
className="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2"
>
<svg
id="theme-toggle-light-icon"
className="w-5 h-5"
fill="yellow"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
);
return (
<div className="mx-2">
<button
id="theme-toggle"
type="button"
suppressHydrationWarning
className="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2"
onClick={() =>
theme == "dark" ? setTheme("light") : setTheme("dark")
}
>
{theme === "dark" ? (
<svg
id="theme-toggle-dark-icon"
className="w-5 h-5"
fill="rgb(156, 163, 175)"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
id="theme-toggle-light-icon"
className="w-5 h-5"
fill="yellow"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
)}
</button>
</div>
);
}
This has to be a client component as the localStorage
and other browser preference won't be available in server environment.
Now, if you're like me where you want to display contents using GFM (Github Flavored Markdown) styles just because, you definitely want to provide both the light and dark themes of GFM into your app. You might want to be like me, using an existing GFM CSS, importing it into your page styles, and call it a day. Then you tried to change the theme by using your very custom made theme switcher.
Except that it didn't work. Of course it shouldn't be that straightforward, ain't it?
prefers-color-scheme
vs Class-based StylingUsing Tailwind CSS will give you freedom to set styles for dark mode using it's classes. Basically prefix your class names with dark:
and it will activate when you switch the theme. Simple! But why my GFM styles are not switching?
I inspected to the actual .css file and what I saw is [data-theme]
CSS block that sets up some CSS variables. The variables are mostly for colors used and would switch if the CSS selector matches the data property.
@media (prefers-color-scheme: dark) {
.markdown-body,
[data-theme="dark"] {
/*dark*/
color-scheme: dark;
--color-prettylights-syntax-comment: #8b949e;
--color-prettylights-syntax-constant: #79c0ff;
--color-prettylights-syntax-entity: #d2a8ff;
/* the rest is omitted */
What this tells is that it will listen for any changes on prefers-color-scheme
media query, which is mostly a user setting based on the browser preference. It's not a bad thing as you would instantly get your theme synchronized with user's preference but it's definitely a hassle for switching as you need to go on extra steps like changing your browser preference. It's far better to have a theme switcher that is built-in into the web app itself and give user the freedom it needed from there.
Now, by default Tailwind CSS will listen to media query but that's not going to give our desired freedom. Fortunately, you can set Tailwind to be reactive by adding darkMode: 'class'
on tailwind.config.js
. My end file now looks like this.
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
darkMode: "class",
};
Additionally, with next-themes
I set the attribute="class"
to make next-themes
add the dark
class when desired. I also set enableSystem={false}
to not take the system color preference, making it completely listen to the class only.
"use client";
import { ThemeProvider } from "next-themes";
import React from "react";
export function Provider({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" enableSystem={false}>
{children}
</ThemeProvider>
);
}
This will definitely enable the dark mode with Next.js but my GFM styles still not reacting. I tried many ways to not modify the styling but failed. So in the end, I copied the CSS into my repository, change the media query into simple class selector, and I'm basically done.
/* @media (prefers-color-scheme: dark) {
.markdown-body,
[data-theme="dark"] { */
.markdown-body.mark-dark {
/*dark*/
color-scheme: dark;
--color-prettylights-syntax-comment: #8b949e;
--color-prettylights-syntax-constant: #79c0ff;
--color-prettylights-syntax-entity: #d2a8ff;
/* ----------- */
/* @media (prefers-color-scheme: light) {
.markdown-body,
[data-theme="light"] { */
.markdown-body {
/*light*/
color-scheme: light;
--color-prettylights-syntax-comment: #57606a;
--color-prettylights-syntax-constant: #0550ae;
--color-prettylights-syntax-entity: #6639ba;
As you can see, the variables would only be set for elements that has markdown-body
class, and dark theme would be switched into for elements that has markdown-body
and mark-dark
class. This will be used on the next part.
So with this setup, the way I used everything is by creating a client component with solve purpose to render parts that would need GFM styling applied to it.
"use client";
import { useTheme } from "next-themes";
import React, { useEffect, useState } from "react";
interface Props {
content: string;
}
const PostContainer: React.FC<Props> = ({ content }) => {
const { theme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return (
<div
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: content }}
className={`mx-2 p-4 lg:mx-10 mt-10 font-sans font-normal markdown-body ${
theme === "dark" ? "mark-dark" : ""
} bg-white dark:bg-slate-900`}
/>
);
};
export default PostContainer;
And I'm done! This passage you're currently reading will take the GFM styling, and try clicking the sun/moon icon on the top navbar to switch the theme.
Back to Home