DEV Community

Cover image for Implementing Dark Mode and Theme Switching using Tailwind v4 and Next.js
Fin Chen
Fin Chen

Posted on • Edited on

Implementing Dark Mode and Theme Switching using Tailwind v4 and Next.js

Dark mode support has become a fundamental aspect of modern web applications, and I recently tackled this feature for my personal blog using Tailwind CSS v4 beta with Next.js 15. In this article, I'll walk through my journey of implementing a dynamic theme toggle, share my learnings of both frameworks.

My Goal: Customizable Website Appearance

Before diving into the implementation details, let's examine what we're building. The goal is to create a theme selection feature that provides a seamless experience while respecting user preferences. When visiting the site, users will have three theme options available: Light, Dark, and Auto (System setting). This behavior mirrors Stack Overflow's theme selection system, which provides an intuitive and well-tested approach to theme management.

Stack Overflow's theme selection

This mechanism use Auto mode as default, which adapts to the user's device theme preferences. Users can then override this by selecting either Light or Dark mode, and their choice will persist across visits to provide a consistent experience. This combination of intelligent defaults and preserved user preferences ensures the site remains comfortable to use across different viewing conditions.

Getting Started with Tailwind v4

To begin experimenting with Tailwind v4, I first needed to set up the framework in my project. The installation process proved remarkably straightforward, even simpler than its predecessor, version 3.

Working with Next.js, the setup process consisted of just three simple steps. The first step was installing the required packages through npm: reference

npm install tailwindcss@next @tailwindcss/postcss@next
Enter fullscreen mode Exit fullscreen mode

Next, update postcss configuration to use Tailwind's new PostCSS plugin. The configuration is notably simpler compared to previous versions, requiring just a single plugin:

export default {
  plugins: {
    '@tailwindcss/postcss': {},
  },
};
Enter fullscreen mode Exit fullscreen mode

Finally, imported Tailwind into main CSS file using a straightforward import statement:

@import "tailwindcss";
Enter fullscreen mode Exit fullscreen mode

Step 0: Leveraging Tailwind's Default Dark Mode

Default behavior of tailwind dark variant

Tailwind provides built-in support for dark mode through its dark variant. When we use utility classes like text-black dark:text-white, Tailwind will automatically switch between these styles based on the user's system preferences.

Let's look at a simple blog post card component and understand how Tailwind handles its dark mode styling:

function BlogCard({ title, content }) {
  return (
    <article className="text-black bg-white dark:text-white dark:bg-black p-4 rounded-lg">
      <h2 className="text-xl text-gray-900 dark:text-gray-100">{title}</h2>
      <p className="text-gray-600 dark:text-gray-300">{content}</p>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

When Tailwind processes our utility classes, it generates CSS that uses the prefers-color-scheme media query. For example, the dark:text-white class gets transformed into:

.dark\:text-white {
    @media (prefers-color-scheme: dark) {
        color: var(--color-white);
    }
}
Enter fullscreen mode Exit fullscreen mode

This CSS transformation reveals two important aspects of how Tailwind v4 works. First, it uses the prefers-color-scheme media query to detect system theme preferences. When a user's system is set to dark mode, any styles within this media query automatically activate. Second, and notably in v4, it leverages CSS variables for colors. Instead of hardcoding color values, Tailwind now uses CSS custom properties (like var(--color-white)), making the entire theming system more flexible and performant. These variables are defined at the root level of your CSS, allowing for easy theme customization and manipulation.

Step 1: Custom Theme Control

Image description

Now, we'll enhance our setup to support explicit theme selection through a dropdown select. This involves three key pieces: configuring Tailwind to respect our custom theme attribute, creating a theme management system, and an user interface for theme selection.

One of the most significant changes when implementing dark mode in v4 is the shift in how dark variants are configured. In Tailwind v3, dark mode configuration was managed through the tailwind.config.js file:

/** For Tailwind v3 */
/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: ['variant', '&:not(.light *)'],
  // ...
}
Enter fullscreen mode Exit fullscreen mode

In v4, we define our dark variant behavior alongside our Tailwind import, in the main css file:

@import "tailwindcss";
@variant dark (&:where([data-theme="dark"]));
Enter fullscreen mode Exit fullscreen mode

Instead of relying heavily on the JavaScript configuration file, v4 moves configuration options directly into CSS. For example, we can now define custom colors as CSS variables (--color-primary: #ff0000;), configure variants using @variant directives.

Now our configuration tells Tailwind to apply dark styles when it finds a data-theme="dark" attribute, rather than relying on system preferences.

Then, we need a place to manage this data-theme attribute. Hence, let’s create our theme management system:

// ThemeProvider.jsx
import { createContext, useContext, useState, useEffect } from 'react';

const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  // Synchronize theme state with HTML data attribute
  useEffect(() => {
    document.documentElement.dataset.theme = theme;
  }, [theme]);

  // Provide theme context to children
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// Custom hook for easy theme access
export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}
Enter fullscreen mode Exit fullscreen mode

Then, we create a select component that gives users explicit control over their theme choice:

// ThemeSelect.jsx
export function ThemeSelect() {
  const { theme, setTheme } = useTheme();

  return (
    <select 
      value={theme}
      onChange={(e) => setTheme(e.target.value)}
      className="bg-white dark:bg-gray-800 text-black dark:text-white p-2 rounded border border-gray-300 dark:border-gray-600"
    >
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
  );
}
Enter fullscreen mode Exit fullscreen mode

Finally, we wrap our application with the ThemeProvider and place the ThemeSelect component where we want the theme controls to appear:

// App.jsx
function App() {
  return (
    <ThemeProvider>
      <div className="min-h-screen bg-white dark:bg-gray-900">
        <nav className="p-4">
          <ThemeSelect />
        </nav>
        {/* Rest of your application */}
      </div>
    </ThemeProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now user can change the theme by selecting instead of changing system setting. Next, we'll extend this feature to support an "auto" option that respects system preferences , just like phase 0.

A Note About Generated CSS and Browser Compatibility

Tailwind v4 beta generates dark mode utilities using modern CSS nesting syntax:

.dark\:text-white {
    &:where([data-theme="dark"], [data-theme="dark"] *) {
        color: var(--color-white);
    }
}
Enter fullscreen mode Exit fullscreen mode

As CSS nesting is a relatively new feature, there are ongoing discussions about browser compatibility in the Tailwind community (see GitHub issue #14753). Since v4 is in beta, this implementation might change based on community feedback and compatibility concerns.

Step 2: System-Aware Theme Switching

After setting up manual theme selection in Phase 1, we're now ready to add system preference detection.

The centerpiece of this enhancement is the window.matchMedia('(prefers-color-scheme: dark)') API. This gives us a way to not only check the current system theme but also respond when it changes. Think of it like subscribing to your system's theme announcements - whenever the user toggles their system theme, our site will hear about it and react accordingly. It’s an javascript equivalent of @media (prefers-color-scheme: dark) .

First, let's update our theme management to react to system preferences:

// ThemeProvider.jsx
import { createContext, useContext, useState, useEffect } from 'react';

const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('auto');

  useEffect(() => {
    // Update data-theme accordingly if user selects light or dark
    if (theme !== 'auto') {
      document.documentElement.dataset.theme = theme;
      return;
    }

    // For auto mode, we need to watch system preferences
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

    // Set initial theme based on system preference
    document.documentElement.dataset.theme = mediaQuery.matches ? 'dark' : 'light';

    // Update theme when system preference changes
    function handleChange(e) {
      document.documentElement.dataset.theme = e.matches ? 'dark' : 'light';
    }

    mediaQuery.addEventListener('change', handleChange);
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now we expand our select component to include the auto option:

export function ThemeSelect() {
  const { theme, setTheme } = useTheme();

  return (
    <select 
      value={theme}
      onChange={(e) => setTheme(e.target.value)}
    >
      <option value="light">Light</option>
      <option value="dark">Dark</option>
      <option value="auto">Auto</option>
    </select>
  );
}
Enter fullscreen mode Exit fullscreen mode

Users can now choose between explicit light/dark themes or let their system preferences automatically control the theme. When in auto mode, the site smoothly transitions between themes as system preferences change, creating a seamless experience that respects both user choice and system settings.

Step 3: Persisting Theme Preferences

Our theme system works well, but it resets to 'auto' whenever the user refreshes the page. Let's improve the user experience by remembering their theme preference using localStorage. This way, when users return to our site, they'll see their chosen theme immediately.

We'll modify our ThemeProvider to read and write to localStorage:

// ThemeProvider.jsx

const getInitialTheme = () => {
  // Handle server-side rendering scenario
  if (typeof window === 'undefined') return 'auto';

  // Get theme from localStorage, defaulting to 'auto' if not found
  return localStorage.getItem('theme') || 'auto';
}

export function ThemeProvider({ children }) {
  // Initialize state with the result of getInitialTheme()
  const [theme, setTheme] = useState(getInitialTheme);

  // Rest of the provider implementation...
  useEffect(() => {
    localStorage.setItem('theme', theme);
  }, [theme]);

  useEffect(() => {
    if (theme !== 'auto') {
      document.documentElement.dataset.theme = theme;
      return;
    }

    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    document.documentElement.dataset.theme = mediaQuery.matches ? 'dark' : 'light';

    function handleChange(e) {
      document.documentElement.dataset.theme = e.matches ? 'dark' : 'light';
    }

    mediaQuery.addEventListener('change', handleChange);
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

A critical aspect is ensuring we have a valid theme value before the component even mounts by separating the initial theme logic into getInitialTheme, and pass it directly to useState. This prevents any potential flash of incorrect theme during the initial render.

Now user preferences can persist across browser sessions, creating a more consistent and personalized experience. Whether a user prefers explicit light/dark modes or automatic system-based switching, their choice will be remembered the next time they visit our site.

A Note About System Preferences and Browser Behavior

Our matchMedia implementation detects theme preferences as reported by the browser, not directly from the operating system. Some browsers, like Arc, have their own theme settings that can override the system preferences. This means in 'auto' mode, the theme will respond to whatever the browser reports as the current preference, which might come from either the system or browser-specific settings.

Customizing appearance in Arc will override color scheme detection

Bonus Step: Using next-themes Library

While building a custom theme implementation has been instructive, in real world we might want a shortcut, this is where you may consider using the next-themes library.

To switch to next-themes, we first install the package:

npm install next-themes
Enter fullscreen mode Exit fullscreen mode

Then we can replace our custom ThemeProvider with the one from next-themes:

import { ThemeProvider } from 'next-themes'

function App({ Component, pageProps }) {
  return (
    <ThemeProvider attribute="data-theme">
      <Component {...pageProps} />
    </ThemeProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode

next-themes replaces the ThemeProvider we just created, with the same useTheme hook:

import { useTheme } from 'next-themes'

export function ThemeSelect() {
  const { theme, setTheme } = useTheme();

  return (
    <select 
      value={theme}
      onChange={(e) => setTheme(e.target.value)}
    >
      <option value="light">Light</option>
      <option value="dark">Dark</option>
      <option value="auto">Auto</option>
    </select>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why do I get server/client mismatch error?

When using useTheme, you will use see a hydration mismatch error when rendering UI that relies on the current theme. This is because many of the values returned by useTheme are undefined on the server, since we can't read localStorage until mounting on the client. Add a suppressHydrationWarning to the html tag to ignore this. See ++example++.

https://github.com/pacocoursey/next-themes/tree/main?tab=readme-ov-file#faq

In this implementation, we've actually built several core features that mirror next-themes:

  • Theme management through ThemeProvider

  • Automatic handling of browser storage synchronization

  • Protection against flash of wrong theme on page load

  • Support for system theme changes across all modern browsers

While next-themes provides additional fine-grained features, understanding how to build a theme system from scratch, as we did in the previous steps, gives valuable insights into how theme switching works under the hood.

Conclusion

Through this implementation journey, I've learned a lot: from Tailwind v4's elegant CSS-based approach and leveraging the matchMedia API for system preferences, to solving the flash of wrong theme challenge. Hope this article helps you understand the nuances of implementing dark mode and inspires you to create more accessible and user-friendly web applications.

References

If this article illuminated your coding journey or solved a tricky problem, a virtual tea is a heartfelt way to show support and fuel more in-depth technical explorations. Your support keeps knowledge flowing! Buy me a cup of black tea 🫖

This post is also on my personal website https://www.thingsaboutweb.dev/en/posts/dark-mode-with-tailwind-v4-nextjs

Top comments (0)