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-themeAttribute — Adata-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 mode (default) -->
<html data-theme="light">
<!-- Dark mode -->
<html data-theme="dark">
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.
/* 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-themeattribute 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.
<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.
<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.
// 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.
// 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.
<!-- 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">
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-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
// 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
/* 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.
/* 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.
[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
[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
[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 |
[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.
Transparent SVG adapts
Same SVG on dark background
<!-- 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.
/* 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.
Dark logo on light bg
Light logo on dark bg
/* 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;
}
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.
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.
// 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.
Welcome back
Your server is running smoothly.
<!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>
/* =============================================
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;
}
}