Skip to content

Dark Mode

Implement a complete dark mode experience using CSS variables and the data-theme attribute. Nimbus makes it effortless.

How Dark Mode Works

Nimbus's dark mode is powered by two core mechanisms working together:

  • CSS Custom Properties — All colors, backgrounds, and border values are defined as CSS variables on :root.
  • data-theme Attribute — A data-theme="dark" attribute on the <html> element overrides those variables with dark-appropriate values.

This means no duplicated selectors or conditional logic. A single attribute change swaps every color in the UI instantly.

Light vs Dark Side-by-Side
Light Mode
Dark Mode
HTML
<!-- Light mode (default) -->
<html data-theme="light">

<!-- Dark mode -->
<html data-theme="dark">
Info Dark mode requires the data-theme attribute on the <html> element and the JavaScript theme toggle module.

How It Works

Nimbus defines all design tokens as CSS custom properties. When data-theme="dark" is set on the <html> element, a CSS selector overrides the default values.

The Override System

Every color variable is defined in two places: the base :root definition for light mode, and the [data-theme="dark"] selector for dark mode.

CSS
/* Light mode (default) */
:root {
    --hu-bg: #ffffff;
    --hu-bg-secondary: #fafafa;
    --hu-text: #171717;
    --hu-text-secondary: #525252;
    --hu-border: #e5e5e5;
    --hu-primary: #2563eb;
}

/* Dark mode overrides */
[data-theme="dark"] {
    --hu-bg: #0a0a0a;
    --hu-bg-secondary: #141414;
    --hu-text: #fafafa;
    --hu-text-secondary: #a3a3a3;
    --hu-border: #262626;
    --hu-primary: #60a5fa;
}

Why CSS Variables?

Using CSS variables for dark mode provides several advantages over class-based approaches:

  • Zero FOUC — Set the data-theme attribute in your HTML to apply the correct theme before paint.
  • No specificity battles — A single attribute selector overrides all base variables equally.
  • Instant switching — Changing one attribute updates every component simultaneously.
  • Cascading scope — Override dark variables on any container for scoped theme sections.

Variable Reference

Variable Light Value Dark Value Description
--hu-bg #ffffff #0a0a0a Main background
--hu-bg-secondary #fafafa #141414 Secondary background
--hu-text #171717 #fafafa Primary text color
--hu-text-secondary #525252 #a3a3a3 Secondary text color
--hu-text-muted #a3a3a3 #525252 Muted text color
--hu-border #e5e5e5 #262626 Default border color
--hu-border-strong #d4d4d4 #404040 Strong border color
--hu-primary #2563eb #60a5fa Primary brand color
--hu-primary-hover #1d4ed8 #93bbfd Primary hover color
--hu-success #16a34a #22c55e Success state color
--hu-warning #f59e0b #fbbf24 Warning state color
--hu-danger #dc2626 #ef4444 Danger state color
--hu-info #0ea5e9 #38bdf8 Info state color

Theme Toggle

The theme toggle button lets users manually switch between light and dark mode. Nimbus's JavaScript handles the toggle, icon swap, and persistence automatically.

Basic Toggle Button

Add a button with the data-theme-toggle attribute anywhere in your page. The JavaScript module will bind click events and manage the data-theme attribute.

Toggle Demo
Click to toggle light/dark mode
HTML
<button data-theme-toggle aria-label="Toggle theme">
    <svg class="hu-icon-sun">
        <!-- Sun icon -->
    </svg>
    <svg class="hu-icon-moon" style="display:none;">
        <!-- Moon icon -->
    </svg>
</button>

Icon-Only Toggle

Use the .hu-btn-icon class for a compact square toggle button.

Icon-Only Toggle
Icon-only toggle button
HTML
<button class="hu-btn hu-btn-ghost hu-btn-icon"
        data-theme-toggle
        aria-label="Toggle theme">
    <svg class="hu-icon-sun">...</svg>
    <svg class="hu-icon-moon" style="display:none;">...</svg>
</button>

Programmatic Toggle

Use the JavaScript API to toggle themes programmatically.

JavaScript
// Toggle the current theme
Nimbus.Theme.toggle();

// Set a specific theme
document.documentElement.setAttribute('data-theme', 'dark');

// Read the current theme
const current = document.documentElement.getAttribute('data-theme');
console.log(current); // "dark" or "light"

localStorage Persistence

The theme preference is saved to localStorage so returning visitors see their chosen theme without flashing.

How It Works

When a user toggles the theme, the JavaScript module stores the preference under the hu-theme key. On page load, the saved value is read and applied before any content renders.

JavaScript
// What the theme module does internally:

// 1. On page load, read saved preference
const saved = localStorage.getItem('hu-theme');

// 2. Apply saved theme (or detect system preference)
if (saved) {
    document.documentElement.setAttribute('data-theme', saved);
} else {
    // Fall back to system preference
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
}

// 3. On toggle, save the new preference
function setTheme(theme) {
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem('hu-theme', theme);
}

The localStorage Key

Key Values Description
hu-theme "light" | "dark" Stores the user's chosen theme preference

Preventing Flash of Wrong Theme (FOUC)

To avoid a flash of the wrong theme on page load, set the data-theme attribute inline in your HTML. The JavaScript will update it once the saved preference is loaded.

HTML
<!-- Inline script to prevent FOUC -->
<script>
    (function() {
        const saved = localStorage.getItem('hu-theme');
        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
        const theme = saved || (prefersDark ? 'dark' : 'light');
        document.documentElement.setAttribute('data-theme', theme);
    })();
</script>

<!-- Or set a default directly in the HTML tag -->
<html lang="en" data-theme="light">
Warning Never set data-theme to "auto". The system only recognizes "light" and "dark" as valid values.

System Preference

Nimbus respects the user's operating system dark mode preference via the prefers-color-scheme media query.

Automatic Detection

When no saved preference exists in localStorage, the theme module checks the system preference and applies the matching theme.

CSS
/* CSS-only system preference detection (no JS needed) */
@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
        --hu-bg: #0a0a0a;
        --hu-bg-secondary: #141414;
        --hu-text: #fafafa;
        --hu-text-secondary: #a3a3a3;
        --hu-border: #262626;
    }
}

JavaScript Detection

JavaScript
// Check if the user prefers dark mode
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
console.log(prefersDark); // true or false

// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)')
    .addEventListener('change', (e) => {
        // Only auto-switch if user hasn't set a manual preference
        if (!localStorage.getItem('hu-theme')) {
            document.documentElement.setAttribute(
                'data-theme',
                e.matches ? 'dark' : 'light'
            );
        }
    });

Detection Priority

Priority Source Description
1 localStorage Previously saved user preference (highest priority)
2 prefers-color-scheme System/OS dark mode setting
3 Default Falls back to light mode if no preference detected

Customizing Dark Colors

Override any dark mode variable to match your brand. Define your overrides in a custom CSS file loaded after main.css.

Global Dark Overrides

Custom Branded Dark Mode

Background: custom dark navy | Primary: custom indigo | Borders: custom muted purple

CSS
/* custom.css - Override dark mode variables */
[data-theme="dark"] {
    /* Brand primary */
    --hu-primary: #818cf8;
    --hu-primary-hover: #a5b4fc;

    /* Backgrounds */
    --hu-bg: #0f0f23;
    --hu-bg-secondary: #1a1a3e;

    /* Text */
    --hu-text: #e2e8f0;
    --hu-text-secondary: #94a3b8;

    /* Borders */
    --hu-border: #2d2d5e;
    --hu-border-strong: #4a4a8a;
}

Scoped Dark Overrides

Override dark mode variables on a specific container to create themed sections.

Scoped Purple Section

This container overrides the default dark colors with a purple accent.

CSS
/* Scoped override on a specific container */
[data-theme="dark"] .purple-section {
    --hu-bg: #1a0f2e;
    --hu-bg-secondary: #2d1b4e;
    --hu-primary: #a78bfa;
    --hu-border: #4c1d95;
}

/* Usage */
<div data-theme="dark" class="purple-section">
    ...content with purple dark theme...
</div>

Component-Specific Overrides

Fine-tune individual components for dark mode by targeting their CSS classes within the [data-theme="dark"] selector.

Card Overrides

Custom Dark Card

This card has a slightly elevated background in dark mode.

CSS
[data-theme="dark"] .hu-card {
    background: var(--hu-bg-secondary);
    border-color: var(--hu-border);
}

[data-theme="dark"] .hu-card-header {
    border-bottom-color: var(--hu-border);
}

[data-theme="dark"] .hu-card-footer {
    border-top-color: var(--hu-border);
}

Button Overrides

CSS
[data-theme="dark"] .hu-btn-outline {
    border-color: var(--hu-border-strong);
    color: var(--hu-text);
}

[data-theme="dark"] .hu-btn-outline:hover {
    background: var(--hu-bg-secondary);
    border-color: var(--hu-primary);
}

[data-theme="dark"] .hu-btn-ghost:hover {
    background: var(--hu-bg-secondary);
}

Form Overrides

CSS
[data-theme="dark"] .hu-form-control {
    background: var(--hu-bg-secondary);
    border-color: var(--hu-border);
    color: var(--hu-text);
}

[data-theme="dark"] .hu-form-control:focus {
    border-color: var(--hu-primary);
    box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.15);
}

[data-theme="dark"] .hu-form-control::placeholder {
    color: var(--hu-text-muted);
}

Table Overrides

Plan Price Servers
Starter $9.99/mo 1
Pro $29.99/mo 5
CSS
[data-theme="dark"] .hu-api-table {
    border-color: var(--hu-border);
}

[data-theme="dark"] .hu-api-table th {
    background: var(--hu-bg-secondary);
    border-bottom-color: var(--hu-border);
    color: var(--hu-text);
}

[data-theme="dark"] .hu-api-table td {
    border-bottom-color: var(--hu-border);
    color: var(--hu-text-secondary);
}

[data-theme="dark"] .hu-api-table tr:hover td {
    background: var(--hu-bg-secondary);
}

Images & Media

Handling images in dark mode requires care. A white-background image on a dark page looks jarring. Here are best practices.

Transparent PNGs/SVGs

The best approach is to use images with transparent backgrounds. SVGs and PNGs with alpha channels automatically adapt to any background color.

Light Mode

Transparent SVG adapts

Dark Mode

Same SVG on dark background

HTML
<!-- Best: SVG with no fixed background -->
<img src="logo.svg" alt="Logo">

<!-- Good: PNG with transparent background -->
<img src="icon.png" alt="Icon">

<!-- Avoid: JPG with baked-in white background -->
<img src="photo.jpg" alt="Photo">

CSS Filters for Photos

For photos that must have a fixed background, use CSS filters to adjust brightness and contrast in dark mode.

CSS
/* Slightly darken photos in dark mode */
[data-theme="dark"] img {
    filter: brightness(0.9) contrast(1.05);
}

/* Use with a subtle border for context */
[data-theme="dark"] .hu-card img {
    border: 1px solid var(--hu-border);
    border-radius: var(--hu-radius-md);
}

/* Alternative: reduce opacity for decorative images */
[data-theme="dark"] .decoration-img {
    opacity: 0.85;
}

Logos & Branding

Provide separate logo variants for light and dark modes, or use CSS to invert/lighten logos.

Nimbus

Dark logo on light bg

Nimbus

Light logo on dark bg

CSS
/* Option 1: Show/hide separate logo variants */
.logo-dark { display: none; }
.logo-light { display: block; }

[data-theme="dark"] .logo-dark { display: block; }
[data-theme="dark"] .logo-light { display: none; }

/* Option 2: Invert a dark logo for dark mode */
[data-theme="dark"] .logo {
    filter: invert(1) brightness(2);
}

/* Option 3: Use CSS mix-blend-mode */
.logo {
    mix-blend-mode: exclusion;
}
Tip SVGs that use currentColor for strokes/fills automatically inherit the text color, making them ideal for dark mode.

Testing

Thoroughly testing dark mode ensures a polished experience. Here are the recommended approaches.

Browser DevTools

Most browsers let you simulate dark mode without changing your OS settings.

DevTools
Chrome DevTools:
1. Open DevTools (F12)
2. Press Ctrl+Shift+P (Cmd+Shift+P on Mac)
3. Type "Rendering"
4. Set "Emulate CSS media feature prefers-color-scheme" to "dark"

Firefox DevTools:
1. Open DevTools (F12)
2. Open the Inspector tab
3. Click the "Toggle prefers-color-scheme" button in the toolbar

Safari:
1. Enable Develop menu in Preferences
2. Go to Develop > Enter Responsive Design Mode
3. Click the dark mode icon in the toolbar

Testing Checklist

  • All text has sufficient contrast ratio (4.5:1 minimum for normal text)
  • Interactive elements are visible and distinguishable
  • Focus rings are visible on both backgrounds
  • Form inputs have clear borders and readable text
  • Images and icons are appropriately visible
  • Shadows provide subtle depth without being invisible
  • Hover and active states work correctly
  • No flash of wrong theme on page load
  • Theme persists across page reloads
  • System preference is respected when no manual choice is saved

Quick Toggle Script

Paste this in your browser console to quickly toggle dark mode during development.

JavaScript
// Paste in browser console to toggle dark mode
(function() {
    const html = document.documentElement;
    const current = html.getAttribute('data-theme');
    const next = current === 'dark' ? 'light' : 'dark';
    html.setAttribute('data-theme', next);
    console.log('Theme set to:', next);
})();

Complete Example

Here is a full working dark mode implementation with all pieces: HTML setup, toggle button, localStorage persistence, system preference detection, and custom dark overrides.

My Hosting Site

Welcome back

Your server is running smoothly.

HTML
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Hosting Site</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="css/main.css">
    <link rel="stylesheet" href="css/custom.css">

    <!-- Prevent FOUC: apply theme before paint -->
    <script>
        (function() {
            var saved = localStorage.getItem('hu-theme');
            var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
            var theme = saved || (prefersDark ? 'dark' : 'light');
            document.documentElement.setAttribute('data-theme', theme);
        })();
    </script>
</head>
<body>

    <!-- Theme toggle button -->
    <button data-theme-toggle aria-label="Toggle theme">
        <svg class="hu-icon-sun">...sun icon...</svg>
        <svg class="hu-icon-moon" style="display:none;">...moon icon...</svg>
    </button>

    <script src="js/main.js"></script>
</body>
</html>
CSS (custom.css)
/* =============================================
   Dark Mode Customizations
   ============================================= */

/* Override dark mode variables */
[data-theme="dark"] {
    --hu-primary: #818cf8;
    --hu-primary-hover: #a5b4fc;
    --hu-bg: #0f0f23;
    --hu-bg-secondary: #1a1a3e;
    --hu-text: #e2e8f0;
    --hu-text-secondary: #94a3b8;
    --hu-text-muted: #64748b;
    --hu-border: #2d2d5e;
    --hu-border-strong: #4a4a8a;
}

/* Component-specific overrides */
[data-theme="dark"] .hu-card {
    background: var(--hu-bg-secondary);
    border-color: var(--hu-border);
}

[data-theme="dark"] .hu-btn-outline {
    border-color: var(--hu-border-strong);
    color: var(--hu-text);
}

[data-theme="dark"] .hu-form-control {
    background: var(--hu-bg-secondary);
    border-color: var(--hu-border);
    color: var(--hu-text);
}

/* Logo variants */
.logo-dark { display: none; }
.logo-light { display: block; }
[data-theme="dark"] .logo-dark { display: block; }
[data-theme="dark"] .logo-light { display: none; }

/* System preference fallback */
@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
        --hu-primary: #818cf8;
        --hu-bg: #0f0f23;
        --hu-bg-secondary: #1a1a3e;
        --hu-text: #e2e8f0;
        --hu-border: #2d2d5e;
    }
}