Effortless Dark and Light Theme Switching in Next.js: A Proposal
Guide to adding a Next.js theme switcher, tackling SSR and user preferences.
Implementing a Robust Dark Mode in Next.js with Theme Switching
Implementing a properly working dark mode is a seemingly challenging task.
The next-themes
package makes implementing a dark mode much simpler and more efficient. It provides a straightforward way to implement it for Next.js, and, by extension, TailwindCSS.
After a few moments into your implementation, you'll likely consider implementing a theme switcher. - Which is great!
This seemingly easy task, given the API of the next-themes
package, starts to become quite a burden when the developer realizes, that Server-Side Rendering (SSR), the system default theme, and the various ways to determine the user's theme preference all need to be taken into account.
In this post, I am proposing a React component that respects the user's theme preference and avoids hydration mismatches. It is also used in the current version of this blog's implementation, as you can see above.
Feel free to try it out.
The component uses shadcn/ui
and @radix-ui/react-icons
.
With a right-click on the button, the theme can be reset to the user's preference.
The implementation assumes that the developer has a working setup regarding next-themes
, shadcn/ui
as well as Next.js overall.
"use client"; import { Button } from "@/components/ui/button"; import { LaptopIcon, MoonIcon, ReloadIcon, SunIcon, } from "@radix-ui/react-icons"; import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; export function ThemeSwitcher() { // resolvedTheme is the theme that is currently being used, either "light" or "dark", avoiding "system" const { setTheme, resolvedTheme, theme } = useTheme(); const [mounted, setMounted] = useState(false); // Using the useEffect hook to set mounted to true once the component has mounted useEffect(() => setMounted(true), []); function toggleTheme() { if (resolvedTheme === "dark") setTheme("light"); if (resolvedTheme === "light") setTheme("dark"); } function resetToSystem(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) { e.preventDefault(); setTheme("system"); } // If the component has not yet mounted, return a disabled button if (!mounted) return ( <Button variant={"ghost"} size="icon" disabled> <ReloadIcon className="animate-spin" /> </Button> ); if (theme === "system") return ( <Button variant={"ghost"} onContextMenu={resetToSystem} onClick={toggleTheme} size="icon" > <LaptopIcon /> </Button> ); if (resolvedTheme === "dark") return ( <Button variant={"ghost"} // handle right click to reset to system onContextMenu={resetToSystem} onClick={toggleTheme} size="icon" > <MoonIcon /> </Button> ); return ( <Button variant={"ghost"} onContextMenu={resetToSystem} onClick={toggleTheme} size="icon" > <SunIcon /> </Button> ); }