Dark mode in Tailwind CSS lijkt op het eerste gezicht een kwestie van dark: classes toevoegen en klaar. Toch loop ik er in de praktijk regelmatig tegenaan dat dingen niet kloppen: een flits van de verkeerde kleur bij het laden van een pagina, class-conflicten wanneer iemand halverwege het thema wisselt, of een voorkeur die na een refresh vergeten is. Dit artikel beschrijft hoe ik dark mode opzet op een manier die consistent blijft, zonder die irritante randgevallen.
De keuze tussen media en class strategie
Tailwind ondersteunt twee manieren om dark mode te activeren. De standaardinstelling is media, waarbij Tailwind luistert naar de systeemvoorkeur van de bezoeker via de prefers-color-scheme mediaquery. Dat klinkt ideaal, maar zodra je een toggle wil aanbieden in de UI loopt het al snel mis: je hebt dan geen controle meer, want het thema is volledig afhankelijk van het besturingssysteem.
De class strategie geeft meer controle. Tailwind kijkt dan of een bovenliggend element de class dark heeft, doorgaans op de <html> of <body> tag. In tailwind.config.js zet je dit zo in:
// tailwind.config.js
module.exports = {
darkMode: 'class',
content: ['./resources/**/*.{blade.php,js,vue}'],
theme: {
extend: {},
},
plugins: [],
}
Vanaf dat moment activeert dark:bg-gray-900 alleen wanneer er een dark-class op een voorouder staat. Dat geeft je de vrijheid om via JavaScript te wisselen, maar het brengt ook verantwoordelijkheid mee: je moet die class zelf beheren en synchroniseren met de voorkeur die je hebt opgeslagen.
Thema-toggle bouwen zonder flicker
Het bekende flicker-probleem treedt op wanneer de pagina laadt met een lichte achtergrond terwijl de gebruiker dark mode heeft ingesteld. De browser rendert eerst de HTML zonder JavaScript, en pas daarna past je script het thema aan. Dat geeft een zichtbare flits van wit.
De truc is dat je het thema zo vroeg mogelijk instelt, nog vóór de rest van de pagina gerenderd wordt. Dat doe je door een klein inline script bovenaan je <head> te plaatsen, buiten welk module-systeem dan ook:
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<script>
(function() {
const theme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (!theme && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
</script>
<link rel="stylesheet" href="/css/app.css">
</head>
Dit script wordt synchroon uitgevoerd voordat de browser verdere elementen rendert. Wanneer er geen opgeslagen voorkeur is, valt het terug op de systeeminstelling. Zo combineer ik de flexibiliteit van de class strategie met het respecteren van de prefers-color-scheme van de gebruiker.
De toggle zelf bouw ik dan in Alpine.js, waarbij ik de class op document.documentElement aanpas en de keuze opslaat in localStorage:
<div x-data="{
isDark: document.documentElement.classList.contains('dark'),
toggle() {
this.isDark = !this.isDark;
if (this.isDark) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}
}">
<button @click="toggle()" class="p-2 rounded bg-gray-200 dark:bg-gray-700">
<span x-text="isDark ? '☀️' : '🌙'"></span>
</button>
</div>
De state in Alpine wordt geïnitialiseerd op basis van de huidige class op <html>, zodat Alpine en het eerder uitgevoerde inline script altijd synchroon lopen. Dit voorkomt een situatie waarbij de knop de verkeerde stand toont direct na het laden.
Class-conflicten bij component libraries en third-party CSS
Zodra je een third-party componentbibliotheek of eigen CSS combineert met Tailwinds dark mode classes, kunnen er conflicten optreden. Soms overschrijft een externe stylesheet kleuren die jij via dark: classes hebt ingesteld, puur omdat de volgorde in de cascade anders uitpakt.
Het helpt om je eigen dark mode classes altijd via een wrapper-component of utility layer te definiëren. In Tailwinds config maak je daarvoor gebruik van de @layer directive:
/* resources/css/app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100;
}
}
Stijlen in de base layer worden vroeg in de cascade geplaatst, waarna Tailwind utilities ze alsnog kunnen overschrijven. Third-party CSS die ná jouw stylesheet geladen wordt, kan nog steeds problemen geven. In dat geval is de meest betrouwbare aanpak om de externe stylesheet vóór jouw eigen CSS te laden, zodat je eigen stijlen altijd het laatste woord hebben.
Soms zie ik ook dat mensen !important proberen te gebruiken om dit te forceren. Dat werkt even, maar het is een weg die snel doodloopt: als je Tailwind's important-optie in de config aanzet, wordt elke utility voorzien van !important, wat cascade-debugging bijna onmogelijk maakt. Beter is het om de laadvolgorde van stylesheets te controleren en eventueel specifiekere selectors in te zetten.
Omgaan met server-side rendering en Blade templates
Werk je met Laravel Blade en server-side rendering, dan is er nog een extra uitdaging. De server weet niet welk thema de gebruiker heeft gekozen, tenzij je die voorkeur opslaat in een cookie of in de sessie. Als je de class dark alleen via JavaScript instelt, stuurt de server altijd HTML zonder die class mee. Dat is prima voor statische pagina's, maar voor server-gerenderde componenten die op basis van dark mode andere afbeeldingen of SVG-varianten moeten laden, is dat een beperking.
Voor afbeeldingen pak ik dit op met de CSS picture-tag en mediaqueries in combinatie met een overriding class, maar voor Blade-componenten sla ik de voorkeur op als cookie bij de eerste toggle:
// Bij de toggle
document.cookie = `theme=${this.isDark ? 'dark' : 'light'}; path=/; max-age=31536000; SameSite=Lax`;
Aan de Laravel-kant lees ik die cookie uit in een middleware, die de waarde beschikbaar maakt als view variable:
// App\Http\Middleware\DetectTheme.php
public function handle(Request $request, Closure $next): Response
{
$theme = $request->cookie('theme', 'light');
view()->share('theme', $theme);
return $next($request);
}
In het Blade-layout bestand zet ik de class dan direct op de <html> tag, op de server:
<html lang="nl" class="{{ $theme === 'dark' ? 'dark' : '' }}">
Nu verstuurt de server al de juiste class, zodat het inline JavaScript-snippet overbodig wordt voor terugkerende bezoekers. Het script hou ik er toch bij als fallback voor de allereerste bezoek, voordat het cookie gezet is.
Na jaren van projecten waarbij dark mode als bijzaak werd toegevoegd, merk ik dat het opzetten van een solide basis veel problemen later voorkomt. De flicker, de toggle die niet synct, de cookie die vergeten wordt: het zijn elk kleine irritaties op zich, maar samen zorgen ze voor een UI die aanvoelt alsof hij niet helemaal afgemaakt is. Als ik dat allemaal voortijdig dichtschroef, hoef ik er later niet meer aan te denken en kan ik me richten op de rest van de interface.