Dark mode

Learn how to configure and build a dark mode switcher for Tailwind CSS

Tailwind theme

Add this line to your Tailwind CSS theme:

@custom-variant dark (&:where(.dark, .dark *));

Works only in v4. Docs.

JavaScript

Our default JS helper content:

const THEME_KEY = "theme";

/**
 * Apply new theme class
 * 
 * @param {String} theme dark / light 
 */
const apply_theme = (theme) => {
  if (theme === "dark") {
    document.documentElement.classList.add("dark");
  } else {
    document.documentElement.classList.remove("dark");
  }
};

/**
 * Set theme value
 * 
 * @param {String} theme  dark / light || null
 */
export const set_theme = (theme) => {
  let next_theme = theme;

  if (!theme) {
    const is_dark = document.documentElement.classList.contains("dark");
    next_theme = is_dark ? "light" : "dark";
  }

  apply_theme(next_theme);

  // save user selected theme
  localStorage.setItem(THEME_KEY, next_theme);
}

/**
 * Init theme changing
 */
export const init_theme = () => {
  // get user selected theme
  const saved = localStorage.getItem(THEME_KEY);

  if (saved) {
    apply_theme(saved);
  } else {
    // if user doesn't save any theme, use system prefers
    const prefers_dark = window.matchMedia(
      "(prefers-color-scheme: dark)"
    ).matches;
    apply_theme(prefers_dark ? "dark" : "light");
  }

  // remove this block for NextJS case and use the `set_theme` method instead
  // default case with html/js START
  const togglers = document.querySelectorAll("[data-theme-toggler-value]");
  togglers.forEach(toggle => {
    const theme_value = toggle.dataset.themeTogglerValue;

    toggle.addEventListener("click", () => {
      // save user selected theme
      set_theme(theme_value);
    });
  })
  // default case with html/js END
};

/**
 * Listener for manual changing system prefers
 */
export const watch_system_theme = () => {
  const media = window.matchMedia("(prefers-color-scheme: dark)");

  media.addEventListener("change", (event) => {
    apply_theme(event.matches ? "dark" : "light");
  });
};

Then you can init the theme and watch for changes in the color-scheme.

init_theme();
watch_system_theme();

HTML

Example:

// light
<button class="flex dark:hidden bg-black-50 p-2px text-foreground items-center justify-center"
  data-theme-toggler-value="light">
  Light
</button>

// dark button
<button class="hidden dark:flex bg-black-800 rounded-full p-2px text-foreground items-center justify-center"
  data-theme-toggler-value="dark">
  Dark
</button>

Our toggler code:

<button class="flex gap-4px bg-black-50 dark:bg-black-800 rounded-full p-2px text-foreground items-center "
    data-theme-toggler-value>
<span class="flex items-center justify-center bg-background dark:bg-transparent p-4px border border-black-100 dark:border-none rounded-full [&_svg]:w-24px [&_svg]:h-24px">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
  <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
</span>
<span class="flex items-center justify-center bg-transparent dark:bg-background p-4px dark:border border-black-700 rounded-full [&_svg]:w-24px [&_svg]:h-24px">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
  <path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
</span>
</button>

Rails

Create a file named theme_helpers.js in app/javascript/utils/theme_helpers.js using the code from the JS section.

Then init it with Turbo:

// app/javascript/application.js
import { init_theme, watch_system_theme } from "utils/theme_helpers";

document.addEventListener("turbo:load", async () => {
  init_theme();
  watch_system_theme();
});

NextJS

Create a file named theme_helpers.js in utils/theme_helpers.js using the code from the JS section. Remove the default case with html/js block. You can use a default case, but this code doesn’t work well with JSX. It’s better to use the set_theme method instead.

Create a client component to manage the theme:

// components/ThemeProvider.js

'use client';

import { useEffect } from 'react';

import { init_theme, watch_system_theme } from '@/utils/theme_helpers';

// This component runs only on the client and manages theme initialization
export default function ThemeProvider({ children }) {
  useEffect(() => {
    init_theme();
    watch_system_theme();
  }, []);

  return children;
}

Create a global layout in app/layout.js. Example:

import ThemeProvider from '@/components/ThemeProvider';

import './globals.css';

export const metadata = {
  title: 'My App',
  description: 'Next.js dark/light theme example',
};

export default function RootLayout({ children }) {
  return (
    <html lang="en" data-theme="light">
      <body className="transition-colors duration-300">
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

Create a theme toggle button. Example with our toggler:

'use client';

import { useState, useEffect } from 'react';

import { set_theme } from '@/utils/theme_helpers';

export default function ThemeToggle() {
  const [theme, set_local_theme] = useState('light');

  // Initialize local state from current DOM theme
  useEffect(() => {
    const is_dark = document.documentElement.classList.contains("dark");;
    set_local_theme(is_dark ? 'dark' : 'light');
  }, []);

  const toggle_theme = () => {
    const next = theme === 'dark' ? 'light' : 'dark';
    set_theme(next);
    set_local_theme(next);
  };

  return (
    <button 
      onClick={toggle_theme}
      class="flex gap-4px bg-black-50 dark:bg-black-800 rounded-full p-2px text-foreground items-center"
      dataThemeTogglerValue>
      <span class="flex items-center justify-center bg-background dark:bg-transparent p-4px border border-black-100 dark:border-none rounded-full [&_svg]:w-24px [&_svg]:h-24px">
        <svg xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          strokeWidth="1.5"
          stroke="currentColor">
          <path strokeLinecap="round"
            strokeLinejoin="round"
            d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
        </svg>
      </span>
      <span class="flex items-center justify-center bg-transparent dark:bg-background p-4px dark:border border-black-700 rounded-full [&_svg]:w-24px [&_svg]:h-24px">
        <svg xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          strokeWidth="1.5"
          stroke="currentColor">
          <path strokeLinecap="round"
            strokeLinejoin="round"
            d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
        </svg>
      </span>
    </button>
  );
}

Laravel

Create a file named theme_helpers.js in resources/js/theme_helpers.js using the code from the JS section.

// resources/js/app.js

import './bootstrap';
import { init_theme, watch_system_theme } from './theme_helpers.js';

// Run after the DOM is ready
document.addEventListener('DOMContentLoaded', () => {
  init_theme();
  watch_system_theme();
});

Check you base layout for app.js code. Example with Vite:

<!-- resources/views/layouts/app.blade.php -->
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="light">
<head>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="transition-colors duration-300">
    {{ $slot }}
</body>
</html>