Back to Blog

Implementing Dark Mode in Django with JavaScript and CSS

admin
November 30, 2025 4 min read
180 views
Build a beautiful dark mode toggle that persists user preferences using localStorage and CSS custom properties.

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:

/* 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

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:

<!-- 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:

#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

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:

<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:

.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:

<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:

html.dark-mode body {
    background-color: #1a1a2e;
    color: #e8e8e8;
}

Component-Specific Styles

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

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

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:

# 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
@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!

Comments (0)

Please login to leave a comment.

No comments yet. Be the first to comment!