Dark Mode in Laravel: Complete Implementation Guide with Tailwind CSS
Dark mode has become an essential feature in modern web applications. Users expect the ability to switch between light and dark themes, and implementing this feature properly can significantly improve user experience and accessibility. In this guide, we'll explore how to implement dark mode in Laravel applications using Tailwind CSS, proper state management, and best practices.
Why Dark Mode Matters
Dark mode isn't just a trendy feature—it serves real purposes:
- Reduced eye strain in low-light environments
- Battery conservation on OLED displays
- Accessibility improvements for users with light sensitivity
- Modern user expectations across platforms
Setting Up Tailwind CSS for Dark Mode
First, ensure your Tailwind CSS configuration supports dark mode. In your tailwind.config.js:
module.exports = {
darkMode: 'class', // Enable class-based dark mode
content: [
'./resources/**/*.blade.php',
'./resources/**/*.js',
'./resources/**/*.vue',
],
theme: {
extend: {
colors: {
// Custom dark mode colors
dark: {
50: '#f8fafc',
100: '#f1f5f9',
900: '#0f172a',
950: '#020617',
}
}
},
},
}
The darkMode: 'class' setting allows you to toggle dark mode by adding or removing a dark class from your HTML element.
Creating a Theme Toggle Component
Let's build a proper theme toggle using Livewire for state management:
<?php
namespace App\Livewire;
use Livewire\Component;
class ThemeToggle extends Component
{
public $darkMode = false;
public function mount()
{
// Check user preference from session or default to system preference
$this->darkMode = session('dark-mode', false);
}
public function toggleTheme()
{
$this->darkMode = !$this->darkMode;
session(['dark-mode' => $this->darkMode]);
$this->dispatch('theme-changed', darkMode: $this->darkMode);
}
public function render()
{
return view('livewire.theme-toggle');
}
}
And the corresponding Blade view using Aura UI components:
<div class="flex items-center space-x-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ $darkMode ? 'Dark' : 'Light' }} Mode
</span>
<x-aura::button
variant="ghost"
size="sm"
wire:click="toggleTheme"
class="p-2"
>
@if($darkMode)
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"></path>
</svg>
@else
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
@endif
</x-aura::button>
</div>
<script>
document.addEventListener('livewire:initialized', () => {
// Apply theme on component initialization
const darkMode = @js($darkMode);
document.documentElement.classList.toggle('dark', darkMode);
// Listen for theme changes
Livewire.on('theme-changed', (event) => {
document.documentElement.classList.toggle('dark', event.darkMode);
});
});
</script>
Advanced Theme Management with Alpine.js
For a more sophisticated approach that respects system preferences and provides smooth transitions:
// resources/js/theme.js
function initTheme() {
return {
darkMode: false,
init() {
// Check for saved theme preference or default to system preference
this.darkMode = localStorage.getItem('theme') === 'dark' ||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
this.updateTheme();
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
this.darkMode = e.matches;
this.updateTheme();
}
});
},
toggleTheme() {
this.darkMode = !this.darkMode;
localStorage.setItem('theme', this.darkMode ? 'dark' : 'light');
this.updateTheme();
},
updateTheme() {
document.documentElement.classList.toggle('dark', this.darkMode);
// Update meta theme-color for mobile browsers
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (metaThemeColor) {
metaThemeColor.setAttribute('content', this.darkMode ? '#1f2937' : '#ffffff');
}
}
}
}
// Make it globally available
window.initTheme = initTheme;
Implementing Dark Mode in Your Layout
Update your main layout file to support dark mode:
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" x-data="initTheme()" x-init="init()">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<title>{{ $title ?? 'Laravel App' }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-200">
<nav class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<h1 class="text-xl font-semibold">My Laravel App</h1>
</div>
<div class="flex items-center space-x-4">
<!-- Theme Toggle -->
<x-aura::button
variant="ghost"
size="sm"
@click="toggleTheme()"
class="p-2"
>
<svg x-show="!darkMode" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
<svg x-show="darkMode" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"></path>
</svg>
</x-aura::button>
</div>
</div>
</div>
</nav>
<main class="min-h-screen">
{{ $slot }}
</main>
</body>
</html>
Dark Mode Best Practices
1. Use Semantic Color Classes
Instead of hardcoding colors, use semantic classes that work in both themes:
<x-aura::card class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<x-aura::card.header>
<h3 class="text-gray-900 dark:text-gray-100">Card Title</h3>
</x-aura::card.header>
<x-aura::card.body>
<p class="text-gray-600 dark:text-gray-300">
Content that adapts to both light and dark themes.
</p>
</x-aura::card.body>
</x-aura::card>
2. Handle Images and Media
Some images may need different versions for dark mode:
<picture>
<source srcset="{{ asset('images/logo-dark.png') }}" media="(prefers-color-scheme: dark)">
<img src="{{ asset('images/logo-light.png') }}" alt="Logo" class="h-8 w-auto">
</picture>
3. Test Accessibility
Ensure proper contrast ratios in both themes:
/* Custom CSS for enhanced dark mode support */
.dark {
--tw-prose-body: #d1d5db;
--tw-prose-headings: #f9fafb;
--tw-prose-links: #60a5fa;
--tw-prose-bold: #f9fafb;
--tw-prose-counters: #9ca3af;
--tw-prose-bullets: #6b7280;
}
Persisting User Preferences
For authenticated users, consider storing theme preferences in the database:
// Migration
Schema::table('users', function (Blueprint $table) {
$table->string('theme_preference')->default('system');
});
// User model
class User extends Authenticatable
{
protected $fillable = [
'name', 'email', 'password', 'theme_preference'
];
public function getPrefersDarkModeAttribute()
{
return $this->theme_preference === 'dark';
}
}
Conclusion
Implementing dark mode properly requires attention to user experience, accessibility, and technical implementation details. By using Tailwind CSS's built-in dark mode utilities, proper state management with Livewire or Alpine.js, and following accessibility best practices, you can create a seamless theme switching experience.
The key is to think beyond just inverting colors—consider the entire user experience, from respecting system preferences to maintaining proper contrast ratios. With components like those provided by Aura UI, you get dark mode support out of the box, allowing you to focus on the unique aspects of your application while ensuring a consistent, professional appearance across both themes.
Remember to test your dark mode implementation thoroughly across different devices and browsers, and always prioritize accessibility to ensure all users can enjoy your application regardless of their preferred theme.