26.6 Dark Mode with Tailwind and Hugo
Right, dark mode. It’s 2024, and we’re still pretending this is a fancy new feature. Let’s be honest: your users want it, your retinas need it, and implementing it in a static site generator like Hugo with Tailwind CSS is actually a joy once you stop fighting the tools and start letting them do the work for you.
The core idea is simple: we toggle a class (usually .dark) on a root element like <html> or <body>, and Tailwind’s dark: variant does the rest, swapping your color utilities based on that class. The real trick, the part that separates a pro setup from a hacky one, is how you handle the user’s preference, persist their choice, and avoid that awful flash of un-styled content (FOUC) on load.
The Strategy: Respect the OS, Honor the User
We’re going to implement a two-tiered approach. First, we respect the user’s system-level preference using a CSS media query. Then, we give them a manual toggle to override it. Their choice gets saved in localStorage so it sticks around. This is the gold standard.
First, configure Tailwind. In your tailwind.config.js, set the darkMode option to 'class'. This is the most important step. The default is 'media', which only respects the OS preference and gives you no manual control. We need the class-based approach for our toggle to work.
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["**/*.{html,js,go.html,css}"],
// This is the key line:
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}
The Toggle: A Little Bit of JavaScript
We need a button that does three things: toggles the .dark class on the <html> element, updates an aria-label for accessibility, and saves the preference. We’ll put this in a Hugo partial, maybe at layouts/partials/dark-toggle.html.
<!-- layouts/partials/dark-toggle.html -->
<button id="darkModeToggle" aria-label="Toggle dark mode" class="p-2 bg-gray-200 dark:bg-gray-800 rounded">
<!-- You can put icons here for sun/moon, but we'll handle the label with JS -->
<span id="toggleIcon">🌙</span>
</button>
<script>
const htmlEl = document.documentElement;
const btn = document.getElementById('darkModeToggle');
const icon = document.getElementById('toggleIcon');
// Function to get the user's stored preference or fall back to system
function getStoredTheme() {
const storedTheme = localStorage.getItem('theme');
if (storedTheme) {
return storedTheme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Function to set the theme
function setTheme(theme) {
if (theme === 'dark') {
htmlEl.classList.add('dark');
localStorage.setItem('theme', 'dark');
icon.textContent = '☀️';
btn.setAttribute('aria-label', 'Switch to light mode');
} else {
htmlEl.classList.remove('dark');
localStorage.setItem('theme', 'light');
icon.textContent = '🌙';
btn.setAttribute('aria-label', 'Switch to dark mode');
}
}
// Initialize the theme on page load
const initialTheme = getStoredTheme();
setTheme(initialTheme);
// Toggle on button click
btn.addEventListener('click', () => {
const currentTheme = localStorage.getItem('theme');
setTheme(currentTheme === 'dark' ? 'light' : 'dark');
});
</script>
The Hugo Integration: Avoiding the FOUC
Here’s the clever Hugo part. That script above runs after the page loads, which means for a brief moment, the user might see a flash of light mode before the JS kicks in and applies the dark class. It’s jarring and amateurish. We can fix this by inlining a tiny script in the <head> of our base template to set the initial theme before the browser paints anything.
Add this to your layouts/_default/baseof.html inside the <head> tag, before your CSS is loaded:
<!-- layouts/_default/baseof.html -->
<head>
...
<!-- Prevent FOUC by setting theme immediately -->
<script>
const storedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = storedTheme || (systemPrefersDark ? 'dark' : 'light');
if (initialTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>
<!-- Then load your stylesheets -->
{{ $styles := resources.Get "css/main.css" | postCSS (dict "config" "./assets/css/postcss.config.js") }}
...
</head>
This script runs synchronously, right as the HTML is parsed. It checks localStorage and the system preference and applies the .dark class instantly. By the time your CSS is loaded and parsed, the correct theme is already in place. No flash. Magic.
Using It In Your Styles
Now, the easy part. In your Hugo templates or Markdown, use Tailwind’s dark: variant anywhere you need it.
<article class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 p-8">
<h2 class="text-2xl font-bold">My Headline</h2>
<p>This text and background will flip gracefully in dark mode.</p>
</article>
The beauty of this setup is its resilience. If JavaScript is disabled, the inline script and the toggle won’t run, but the Tailwind dark: utilities will still respond to the OS-level preference because of the media query fallback in our logic. It’s progressive enhancement at its finest. You’ve covered every base without breaking a sweat. Now go forth and let your users give their eyeballs a rest. They’ll thank you for it.