Implementing Dark Mode in Django with JavaScript and CSS
# Implementing Dark Mode in Django
Dark mode isn't just a trend—it's an accessibility feature that reduces eye strain. Here's how we implemented it in DjangoZen.
## The Strategy
1. Use CSS custom properties (variables) for colors
2. Toggle a class on the body element
3. Persist preference in localStorage
4. Respect system preferences as default
## CSS Custom Properties
Define your color palette:
```css
/* Light mode (default) */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #e9ecef;
--text-primary: #212529;
--text-secondary: #6c757d;
--text-muted: #adb5bd;
--border-color: #dee2e6;
--card-bg: #ffffff;
--navbar-bg: linear-gradient(135deg, #2d3561 0%, #3d2656 100%);
--shadow: rgba(0, 0, 0, 0.1);
--accent: #667eea;
}
/* Dark mode */
body.dark-mode {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f3460;
--text-primary: #e8e8e8;
--text-secondary: #b8b8b8;
--text-muted: #888888;
--border-color: #2d2d44;
--card-bg: #1e1e32;
--navbar-bg: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
--shadow: rgba(0, 0, 0, 0.3);
--accent: #7c8ef5;
}
```
## Apply Variables to Elements
```css
body {
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.card {
background-color: var(--card-bg);
border-color: var(--border-color);
}
.navbar {
background: var(--navbar-bg);
}
.text-muted {
color: var(--text-muted) !important;
}
.form-control {
background-color: var(--bg-secondary);
border-color: var(--border-color);
color: var(--text-primary);
}
.form-control:focus {
background-color: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--accent);
}
/* Smooth transitions */
.card, .form-control, .btn, .navbar {
transition: background-color 0.3s ease,
border-color 0.3s ease,
color 0.3s ease;
}
```
## The Toggle Button
Add a toggle in your navbar:
```html
<!-- base.html -->
<button id="darkModeToggle"
class="btn btn-link nav-link px-2"
title="Toggle Dark Mode">
<i class="fas fa-moon" id="darkModeIcon"></i>
</button>
```
Style the toggle:
```css
#darkModeToggle {
cursor: pointer;
transition: transform 0.3s ease;
border: none;
background: none;
}
#darkModeToggle:hover {
transform: scale(1.1);
}
#darkModeToggle:hover i {
color: var(--accent);
}
```
## JavaScript Implementation
```javascript
document.addEventListener('DOMContentLoaded', function() {
const darkModeToggle = document.getElementById('darkModeToggle');
const darkModeIcon = document.getElementById('darkModeIcon');
const body = document.body;
// Check for saved preference or system preference
function getPreferredTheme() {
const savedTheme = localStorage.getItem('darkMode');
if (savedTheme !== null) {
return savedTheme === 'enabled';
}
// Check system preference
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
// Apply theme
function applyTheme(isDark) {
if (isDark) {
body.classList.add('dark-mode');
darkModeIcon.classList.remove('fa-moon');
darkModeIcon.classList.add('fa-sun');
} else {
body.classList.remove('dark-mode');
darkModeIcon.classList.remove('fa-sun');
darkModeIcon.classList.add('fa-moon');
}
}
// Initialize on page load
applyTheme(getPreferredTheme());
// Toggle handler
darkModeToggle.addEventListener('click', function() {
const isDark = body.classList.toggle('dark-mode');
// Update icon
if (isDark) {
darkModeIcon.classList.remove('fa-moon');
darkModeIcon.classList.add('fa-sun');
localStorage.setItem('darkMode', 'enabled');
} else {
darkModeIcon.classList.remove('fa-sun');
darkModeIcon.classList.add('fa-moon');
localStorage.setItem('darkMode', 'disabled');
}
});
// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (localStorage.getItem('darkMode') === null) {
applyTheme(e.matches);
}
});
});
```
## Handling Images
Some images need different versions for dark mode:
```html
<picture>
<source srcset="/static/img/logo-dark.png" media="(prefers-color-scheme: dark)">
<img src="/static/img/logo-light.png" alt="Logo" class="theme-aware-image">
</picture>
```
Or with CSS:
```css
.logo-light {
display: block;
}
.logo-dark {
display: none;
}
body.dark-mode .logo-light {
display: none;
}
body.dark-mode .logo-dark {
display: block;
}
```
## Preventing Flash of Wrong Theme
Add this in the `<head>` before CSS loads:
```html
<script>
// Apply theme immediately to prevent flash
(function() {
const savedTheme = localStorage.getItem('darkMode');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'enabled' || (savedTheme === null && prefersDark)) {
document.documentElement.classList.add('dark-mode');
}
})();
</script>
```
And add CSS for `html.dark-mode`:
```css
html.dark-mode body {
background-color: #1a1a2e;
color: #e8e8e8;
}
```
## Component-Specific Styles
### Dropdown Menus
```css
body.dark-mode .dropdown-menu {
background-color: #2d2d44;
border-color: #3d3d5c;
}
body.dark-mode .dropdown-item {
color: #e0e0e0;
}
body.dark-mode .dropdown-item:hover {
background-color: #3d3d5c;
}
body.dark-mode .dropdown-divider {
border-color: #3d3d5c;
}
```
### Tables
```css
body.dark-mode .table {
color: var(--text-primary);
}
body.dark-mode .table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(255, 255, 255, 0.02);
}
body.dark-mode .table-hover tbody tr:hover {
background-color: rgba(255, 255, 255, 0.05);
}
```
### Modals
```css
body.dark-mode .modal-content {
background-color: var(--card-bg);
border-color: var(--border-color);
}
body.dark-mode .modal-header,
body.dark-mode .modal-footer {
border-color: var(--border-color);
}
body.dark-mode .btn-close {
filter: invert(1);
}
```
## User Preference in Database
For logged-in users, persist to database:
```python
# models.py
class UserPreferences(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
dark_mode = models.BooleanField(default=False)
# views.py
@login_required
def toggle_dark_mode(request):
prefs, _ = UserPreferences.objects.get_or_create(user=request.user)
prefs.dark_mode = not prefs.dark_mode
prefs.save()
return JsonResponse({'dark_mode': prefs.dark_mode})
```
## Accessibility Considerations
1. **Contrast ratios**: Ensure WCAG 2.1 AA compliance (4.5:1 for text)
2. **Focus indicators**: Make them visible in both modes
3. **Reduced motion**: Respect `prefers-reduced-motion`
```css
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
}
```
## Testing
Test in multiple browsers and check:
- Toggle works correctly
- Preference persists on refresh
- System preference fallback works
- No flash of wrong theme
- All components styled correctly
Dark mode makes your app more accessible and user-friendly!