Skip to main content

Command Palette

Search for a command to run...

Dark / Light Toggle Mode in NextJS

Published
•6 min read•View as Markdown
Dark / Light Toggle Mode in NextJS
S

I am a software developer, passionate about developing and designing solutions with an emphasis on great user experience.

Implementing a dark/light toggle mode in web applications greatly enhances user experience by respecting users' preferences and comfort. In this article, we’ll explore how to add this feature to a NextJS web application and create an effect like the one shown below.

Setting up a NextJS Application

The first step is to create a NextJS app using the following command.

npx create-next-app@latest

Follow the steps outlined in the official docs.

Install the Next-Themes Library

We’ll use the next-themes library to manage the dark / light toggle mode in our application. The library is specifically designed to handle theme toggling with ease, making it a perfect choice for our needs.

npm i next-themes

Create Theme Toggle Provider component

We’ll now create a ThemeToggleProvider component, which will receive children and any additional props to be passed to NextThemesProvider.

Create a utils folder, and inside it, create a ThemeToggleProvider.tsx file. In the ThemeToggleProvider.tsx file, write the following code.

"use client";

import React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import useMounted from "@/hooks/useMounted";

function ThemeToggleProvider({children, ...props}: React.ComponentPropsWithoutRef<typeof NextThemesProvider>) {
  const mounted = useMounted();

  // if component is not mounted, return null
  if (!mounted) return null;

  // else render the NextThemesProvider component with received props and children
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

export default ThemeToggleProvider;

As shown above, we use the useMounted hook to prevent hydration errors that can occur when switching between dark and light themes. This hook returns a boolean value indicating whether the component has finished mounting (i.e., rendering) on the screen. The code for the hook is shown below.

"use client";

import { useState, useEffect } from "react";

function useMounted() {
  const [mounted, setMounted] = useState<boolean>();

  useEffect(() => {
    setMounted(true);
  }, []);

  return mounted;
}

export default useMounted;

Use the ThemeToggleProvider in the Application

It’s time to use the ThemeToggleProvider we created earlier. Inside the app/layout.tsx file, we’ll import the component and wrap the children with it. Our app/layout.tsx file should now look like this.

import type { Metadata } from "next";
import React, { Suspense } from "react";
import { Poppins, Nunito_Sans } from "next/font/google";
import clsx from "clsx";

import ThemeToggleProvider from "@/utils/ThemeToggleProvider";
import Spinner from "@/components/ui/Spinner";
import "./globals.css";

// Poppins Font
const mainFont = Poppins({
  subsets: ["latin"],
  display: "fallback",
  weight: ["400", "500", "600", "700", "800", "900"],
  variable: "--font-poppins",
});
// Nunito Sans Font
const monoFont = Nunito_Sans({
  subsets: ["latin"],
  display: "fallback",
  weight: ["400", "500", "600", "700", "800", "900"],
  variable: "--font-nunito-sans",
});

// Metadata
export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${clsx(mainFont.variable, monoFont.variable)} `}>
        {/* ThemeToggleProvider for light/dark theme */}
        <ThemeToggleProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
        >
          <Suspense fallback={<Spinner />}>{children}</Suspense>
        </ThemeToggleProvider>
      </body>
    </html>
  );
}

At this point, we have set up our application to use both dark and light modes. Now, we'll create a toggle button to switch between these two modes.

Create a Toggle Button component

Let's create an animated toggle button, as shown above, to switch between the two modes. Create a ThemeSwitcher.tsx file with the following code. We will use the useTheme hook from next-themes to get the current theme and manage theme switching and animation style, and motion for the SVG animation.

"use client";

import React from "react";
import { useTheme } from "next-themes";
import { motion } from "motion/react";

// properties for animation of light / dark button
export const animationProperties = {
  dark: {
    circle: {
      r: 9,
    },
    mask: {
      cx: "50%",
      cy: "23%",
    },
    svg: {
      transform: "rotate(40deg)",
    },
    lines: {
      opacity: 0,
    },
  },
  light: {
    circle: {
      r: 5,
    },
    mask: {
      cx: "100%",
      cy: "0%",
    },
    svg: {
      transform: "rotate(90deg)",
    },
    lines: {
      opacity: 1,
    },
  },
};

function ThemeSwitcher() {
    const { theme, setTheme, systemTheme } = useTheme();

    // get current theme
    const currentTheme = theme === "system" ? systemTheme : theme;

    // get animation state based on current theme
    const animationState = animationProperties[currentTheme === "dark" ? "light" : "dark"];

    return (
        <button
          onClick={() => setTheme(currentTheme === "dark" ? "light" : "dark")}
          className="hover:text-white"
          type="button"
          aria-label="Toggle dark / light mode"
        >
          <motion.svg
            xmlns="http://www.w3.org/2000/svg"
            width="24"
            height="24"
            viewBox="0 0 24 24"
            fill="none"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
            stroke="currentColor"
            animate={animationState.svg}
            transition={{ type: "spring", stiffness: 250, damping: 35 }}
          >
            <mask id="logoMask">
              <rect x="0" y="0" width="100%" height="100%" fill="white" />
              <motion.circle
                animate={animationState.mask}
                r="7"
                fill="black"
                transition={{ type: "spring", stiffness: 250, damping: 35 }}
              />
            </mask>

            <motion.circle
              cx="12"
              cy="12"
              animate={animationState.circle}
              fill="currentColor"
              stroke="none"
              mask="url(#logoMask)"
              transition={{ type: "spring", stiffness: 250, damping: 35 }}
            />

            <motion.g
              stroke="currentColor"
              strokeWidth="2.5"
              animate={animationState.lines}
              transition={{ duration: 0.3 }}
            >
              <line x1="12" y1="1" x2="12" y2="3" />
              <line x1="12" y1="21" x2="12" y2="23" />
              <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
              <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
              <line x1="1" y1="12" x2="3" y2="12" />
              <line x1="21" y1="12" x2="23" y2="12" />
              <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
              <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
            </motion.g>
          </motion.svg>
        </button>
    );
}

export default ThemeSwitcher;

The application can now implement the ThemeSwitcher component, for example, in the header section. We’ll implement it before the children in the app/layout.tsx file.

import type { Metadata } from "next";
import React, { Suspense } from "react";
import { Poppins, Nunito_Sans } from "next/font/google";
import clsx from "clsx";

import ThemeToggleProvider from "@/utils/ThemeToggleProvider";
import ThemeSwitcher from "@/components/ThemeSwitcher";
import Spinner from "@/components/ui/Spinner";
import "./globals.css";

// Poppins Font
const mainFont = Poppins({
  subsets: ["latin"],
  display: "fallback",
  weight: ["400", "500", "600", "700", "800", "900"],
  variable: "--font-poppins",
});
// Nunito Sans Font
const monoFont = Nunito_Sans({
  subsets: ["latin"],
  display: "fallback",
  weight: ["400", "500", "600", "700", "800", "900"],
  variable: "--font-nunito-sans",
});

// Metadata
export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${clsx(mainFont.variable, monoFont.variable)} `}>
        {/* ThemeToggleProvider for light/dark theme */}
        <ThemeToggleProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
        >
          <ThemeSwitcher />
          <Suspense fallback={<Spinner />}>{children}</Suspense>
        </ThemeToggleProvider>
      </body>
    </html>
  );
}

Apply styling effects based on dark or light mode

We can now style the application based on its respective theme. We’ll use Tailwind CSS for styling.

In the tailwind.config.ts file, add darkMode: 'class'. This darkMode setting makes it easier to add styles specific to dark mode.

import type { Config } from "tailwindcss";

export default {
  content: [
    "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  darkMode: "class",
  theme: {},
  },
  plugins: [],
} satisfies Config;

Now, we can add a dark: prefix before any Tailwind class to apply a style that will appear in dark mode. For example:

<h1 className="text-black dark:text-white"></h1>

Conclusion

In conclusion, using the next-themes library makes adding a dark/light toggle mode in a NextJS app much easier as it offers hooks and components for easy integration. The library works well with styling frameworks like Tailwind CSS, simplifying the application of theme-specific styles. This approach not only simplifies development but also enhances user experience by providing a dark mode option.