Getting started
Components
Extra
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>