Skip to content

Dark Mode and Theming

This guide explains how dark mode works in django-admin-deux and how to customize themes.

Overview

django-admin-deux includes built-in dark mode support with automatic system preference detection and manual toggle capability. The dark mode is provided by the default theme plugin and persists user preferences using localStorage.

How Dark Mode Works

Automatic Detection

Dark mode automatically detects your system preference:

// Checks system preference
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

Priority Order: 1. User's saved preference in localStorage (highest priority) 2. System preference (prefers-color-scheme: dark) 3. Light mode (default fallback)

Manual Toggle

A toggle button appears in the top-right header:

┌────────────────────────────────┐
│  Django Admin  [User] [🌙]    │
│                         ↑      │
│                  Dark mode     │
│                  toggle        │
└────────────────────────────────┘

Clicking the toggle: - Switches between light and dark modes - Saves preference to localStorage - Preference persists across sessions

localStorage Persistence

Your preference is stored in localStorage:

// Saved values
localStorage.setItem('theme', 'dark');  // Dark mode
localStorage.setItem('theme', 'light'); // Light mode

// Retrieve saved theme
const savedTheme = localStorage.getItem('theme');

System Preference Changes

Dark mode listens for system preference changes:

// Auto-updates when system preference changes (if no saved preference)
window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', function(e) {
    if (!localStorage.getItem('theme')) {
      applyTheme(e.matches);
    }
  });

Note: System preference only applies if the user hasn't manually set a preference.

Using Dark Mode

Enable Dark Mode

Dark mode is enabled by default. Users can:

  1. Use system preference: Set dark mode in their OS, and django-admin-deux will follow
  2. Manual toggle: Click the toggle button in the header
  3. Set preference: Choice is saved and persists across sessions

Clear Saved Preference

To revert to system preference:

// In browser console
localStorage.removeItem('theme');

Then refresh the page. Dark mode will now follow system preference.

Customizing Dark Mode

Dark Mode Colors

The theme uses Tailwind CSS with custom color variables. Colors are defined in the CSS with .dark class variants:

/* Light mode */
body {
  background-color: rgb(243 244 246);
  color: rgb(17 24 39);
}

/* Dark mode */
.dark body {
  background-color: rgb(3 7 18);
  color: rgb(243 244 246);
}

Override Dark Mode Colors

Create a custom CSS file to override colors:

/* static/css/admin-dark-overrides.css */

/* Custom dark mode background */
.dark body {
  background-color: #1a1a2e;
}

/* Custom dark mode card background */
.dark .admin article {
  background-color: #16213e;
  border-color: #0f3460;
}

/* Custom dark mode primary button */
.dark .admin a.primary,
.dark .admin button.primary {
  background-color: #e94560;
}

.dark .admin a.primary:hover,
.dark .admin button.primary:hover {
  background-color: #d63649;
}

Include it in your template:

{# templates/djadmin/admin_base.html #}
{% extends "djadmin/admin_base.html" %}

{% block extra_css %}
    {{ block.super }}
    <link rel="stylesheet" href="{% static 'css/admin-dark-overrides.css' %}">
{% endblock %}

Color Scheme Reference

Default Dark Mode Palette:

Element Light Mode Dark Mode
Body Background #f3f4f6 #030712
Card Background #ffffff #111827
Card Border #d1d5db #1f2937
Text Primary #111827 #f3f4f6
Text Secondary #6b7280 #9ca3af
Primary Button #0c4b33 #0a3d2a
Primary Hover #0a3d2a #082f21
Danger Button #dc2626 #b91c1c

Custom Dark Mode Detection

Override the dark mode detection logic:

// static/js/custom-dark-mode.js

// Disable default dark mode
localStorage.setItem('theme', 'light');

// Implement custom logic
function customDarkModeCheck() {
  const hour = new Date().getHours();
  // Dark mode between 8 PM and 6 AM
  return hour >= 20 || hour < 6;
}

if (customDarkModeCheck()) {
  document.documentElement.classList.add('dark');
} else {
  document.documentElement.classList.remove('dark');
}

Disabling Dark Mode

Method 1: Hide Toggle Button

Hide the toggle button to prevent manual switching:

/* static/css/admin-overrides.css */
#dark-mode-toggle {
  display: none !important;
}

Users will follow system preference only.

Method 2: Force Light Mode

Always use light mode:

// static/js/force-light-mode.js
document.addEventListener('DOMContentLoaded', function() {
  // Remove dark class
  document.documentElement.classList.remove('dark');

  // Clear saved preference
  localStorage.removeItem('theme');

  // Disable toggle
  const toggle = document.getElementById('dark-mode-toggle');
  if (toggle) {
    toggle.style.display = 'none';
  }
});

Include in your template:

{% block extra_js %}
    {{ block.super }}
    <script src="{% static 'js/force-light-mode.js' %}"></script>
{% endblock %}

Method 3: Force Dark Mode

Always use dark mode:

// static/js/force-dark-mode.js
document.addEventListener('DOMContentLoaded', function() {
  // Add dark class
  document.documentElement.classList.add('dark');

  // Save preference
  localStorage.setItem('theme', 'dark');

  // Hide toggle
  const toggle = document.getElementById('dark-mode-toggle');
  if (toggle) {
    toggle.style.display = 'none';
  }
});

Alternative Themes

Creating a Custom Theme Plugin

django-admin-deux uses a plugin system for themes. You can create a custom theme plugin:

1. Create Plugin Structure:

myapp/
├── djadmin_hooks.py
├── static/
│   └── myapp/
│       └── theme.css
└── templates/
    └── djadmin/
        ├── admin_base.html
        └── includes/
            └── styles.html

2. Implement Hook (djadmin_hooks.py):

from djp import hookimpl

@hookimpl
def djadmin_provides_features():
    """Advertise that this plugin provides a theme"""
    return ['theme']

@hookimpl
def djadmin_get_theme_assets():
    """Provide custom theme CSS/JS"""
    return {
        'css': ['myapp/theme.css'],
        'js': ['myapp/theme.js'],
    }

3. Create Base Template (templates/djadmin/admin_base.html):

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}{% endblock %}</title>

    <!-- Your custom theme CSS -->
    <link rel="stylesheet" href="{% static 'myapp/theme.css' %}">

    {% block extra_css %}{% endblock %}
</head>
<body>
    <div class="admin">
        <header>
            {% block header %}
                <!-- Your custom header -->
            {% endblock %}
        </header>

        <main>
            {% block content %}{% endblock %}
        </main>

        <footer>
            {% block footer %}{% endblock %}
        </footer>
    </div>

    <script src="{% static 'myapp/theme.js' %}"></script>
    {% block extra_js %}{% endblock %}
</body>
</html>

4. Install Plugin:

# settings.py
INSTALLED_APPS = [
    'djadmin',
    'djadmin.plugins.core',
    # 'djadmin.plugins.theme',  # Remove default theme
    'myapp',  # Your custom theme plugin
    # ... other apps
]

Theme Plugin Examples

Minimal Theme (no dark mode):

# myapp/djadmin_hooks.py
from djp import hookimpl

@hookimpl
def djadmin_provides_features():
    return ['theme']

@hookimpl
def djadmin_get_theme_assets():
    return {
        'css': ['myapp/minimal-theme.css'],
    }

Bootstrap Theme:

@hookimpl
def djadmin_get_theme_assets():
    return {
        'css': [
            'https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css',
            'myapp/bootstrap-overrides.css',
        ],
        'js': [
            'https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js',
        ],
    }

Material Design Theme:

@hookimpl
def djadmin_get_theme_assets():
    return {
        'css': [
            'https://fonts.googleapis.com/icon?family=Material+Icons',
            'myapp/material-theme.css',
        ],
        'js': ['myapp/material-components.js'],
    }

Tailwind CSS Customization

The default theme uses Tailwind CSS. To customize the Tailwind configuration:

1. Create tailwind.config.js:

// tailwind.config.js
module.exports = {
  content: [
    './templates/**/*.html',
    './static/**/*.js',
  ],
  darkMode: 'class', // Enable class-based dark mode
  theme: {
    extend: {
      colors: {
        // Custom brand colors
        primary: {
          50: '#f0fdf4',
          // ... your color scale
          900: '#052e16',
        },
      },
    },
  },
  plugins: [
    require('@tailwindcss/forms'),
  ],
};

2. Build Custom CSS:

# Install Tailwind
npm install -D tailwindcss @tailwindcss/forms

# Build CSS
npx tailwindcss -i ./src/input.css -o ./static/css/theme.css --watch

3. Use Custom CSS:

{% block extra_css %}
    <link rel="stylesheet" href="{% static 'css/theme.css' %}">
{% endblock %}

Testing Dark Mode

Visual Testing

Test both modes manually:

  1. Toggle dark mode in the admin
  2. Check all pages (dashboard, list, create, update, delete)
  3. Verify colors have sufficient contrast
  4. Test with different screen sizes

Automated Testing

Test dark mode detection:

from django.test import TestCase, Client

class DarkModeTest(TestCase):
    def test_dark_mode_toggle_exists(self):
        """Test that dark mode toggle is present"""
        client = Client()
        response = client.get('/djadmin/')

        self.assertContains(response, 'id="dark-mode-toggle"')

    def test_dark_mode_javascript_loaded(self):
        """Test that dark mode JavaScript is loaded"""
        response = self.client.get('/djadmin/')

        self.assertContains(response, 'initDarkMode')

Accessibility Testing

Verify color contrast in both modes:

# Use tools like:
# - WAVE Browser Extension
# - axe DevTools
# - Chrome Lighthouse

# Test contrast ratios:
# - Normal text: at least 4.5:1
# - Large text: at least 3:1
# - UI components: at least 3:1

Best Practices

1. Maintain Contrast Ratios

Ensure readable text in both modes:

/* Good contrast */
.dark body {
  background: #1a1a1a;  /* Very dark */
  color: #f5f5f5;        /* Very light */
}

/* Poor contrast (avoid) */
.dark body {
  background: #555555;   /* Medium gray */
  color: #888888;        /* Medium gray */
}

2. Test Both Modes

Always test your customizations in both light and dark mode.

3. Respect User Preferences

Don't override user preferences without good reason:

// Good: Respect saved preference
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
  applyTheme(savedTheme === 'dark');
}

// Bad: Always override
applyTheme(true); // Forces dark mode

4. Provide Clear Toggle

Make the dark mode toggle obvious and accessible:

<button id="dark-mode-toggle" aria-label="Toggle dark mode">
  <span class="sun" aria-hidden="true">☀️</span>
  <span class="moon" aria-hidden="true">🌙</span>
</button>

5. Use Semantic Colors

Use semantic color names that work in both modes:

/* Good: Semantic */
.success { color: var(--color-success); }
.error { color: var(--color-error); }

/* Bad: Hard-coded */
.success { color: #00ff00; }  /* Too bright in dark mode */

Troubleshooting

Dark Mode Not Working

Problem: Dark mode toggle doesn't work

Solutions: 1. Check that JavaScript is loaded: View page source, look for admin.js 2. Check browser console for errors 3. Verify localStorage is not disabled 4. Clear localStorage and try again: localStorage.clear()

Dark Mode Flashing

Problem: Page flashes light mode before switching to dark

Solution: Add inline script in <head>:

<script>
  // Apply theme before page renders
  (function() {
    const savedTheme = localStorage.getItem('theme');
    const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    const isDark = savedTheme === 'dark' || (!savedTheme && systemPrefersDark);
    if (isDark) {
      document.documentElement.classList.add('dark');
    }
  })();
</script>

Colors Not Updating

Problem: Custom colors not appearing in dark mode

Solution: Ensure you're using .dark prefix:

/* Wrong */
body {
  background: #1a1a1a;  /* Applies to light mode too */
}

/* Correct */
.dark body {
  background: #1a1a1a;  /* Only applies in dark mode */
}

Toggle Not Visible

Problem: Can't find dark mode toggle

Solution: Toggle is in the header navigation:

{# templates/djadmin/admin_base.html #}
<nav>
  <a href="{% url 'djadmin:index' %}">Django Admin</a>
  <!-- Other nav items -->
  <button id="dark-mode-toggle">🌙</button>
</nav>

See Also