Log 0x1B: Finally, A Dark Theme Switcher

Next.js, Tailwind CSS, and Dark Mode

This post's banner image

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.js and 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.

GFM Styles Integration

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?

Regarding prefers-color-scheme vs Class-based Styling

Using 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.

Usage

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