Dark Mode Implementation: A Complete CSS Guide

A practical walkthrough of building dark mode with CSS custom properties, localStorage persistence, smooth transitions, and image handling. Includes a full theme switcher implementation with accessibility considerations.

Dark Mode Implementation: A Complete CSS Guide, jekcms blog cover

A practical walkthrough of building dark mode with CSS custom properties, localStorage persistence, smooth transitions, and image handling. Includes a full theme switcher implementation with accessibility considerations.

Why Dark Mode Matters

A large share of evening visitors browse with dark mode turned on in their operating system. A bright white page late at night is tiring to look at, and OLED screens make it more noticeable because every white pixel is its own light source.

This guide covers everything involved in implementing proper dark mode support across a jekcms theme: the correct CSS variable structure, the edge cases most tutorials skip, and how to handle third-party components that ignore color-scheme.

The CSS Custom Properties Strategy

The foundation of any maintainable dark mode system is CSS custom properties (variables). If you try to do this with separate stylesheets or class overrides on individual elements, you will end up with an unmaintainable mess within a month.

Here is the core principle: define every color as a custom property on :root, then override those properties when dark mode is active. Every element in your stylesheet references properties, never raw color values.

:root {
  /* Surface colors */
  --color-bg-primary: #ffffff;
  --color-bg-secondary: #f5f5f5;
  --color-bg-tertiary: #e8e8e8;
  --color-bg-elevated: #ffffff;

  /* Text colors */
  --color-text-primary: #1a1a1a;
  --color-text-secondary: #555555;
  --color-text-tertiary: #888888;
  --color-text-inverse: #ffffff;

  /* Border colors */
  --color-border-default: #d0d0d0;
  --color-border-subtle: #e5e5e5;

  /* Brand colors */
  --color-accent: #2563eb;
  --color-accent-hover: #1d4ed8;
  --color-accent-text: #ffffff;

  /* Semantic colors */
  --color-success: #16a34a;
  --color-warning: #ca8a04;
  --color-error: #dc2626;

  /* Shadows */
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
  --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);

  /* Transitions */
  --transition-colors: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
}

Avoid names like --dark-bg or --light-text. Name variables by function, not by appearance. --color-bg-primary is the main background, regardless of whether it is white or dark gray.

The Dark Mode Override

The dark theme overrides these same properties. We use a [data-theme="dark"] attribute on the <html> element rather than a CSS class because data attributes feel semantically correct: they describe a state, not a style.

[data-theme="dark"] {
  --color-bg-primary: #0f0f0f;
  --color-bg-secondary: #1a1a1a;
  --color-bg-tertiary: #262626;
  --color-bg-elevated: #1f1f1f;

  --color-text-primary: #e5e5e5;
  --color-text-secondary: #a3a3a3;
  --color-text-tertiary: #737373;
  --color-text-inverse: #0f0f0f;

  --color-border-default: #333333;
  --color-border-subtle: #262626;

  --color-accent: #60a5fa;
  --color-accent-hover: #93c5fd;
  --color-accent-text: #0f0f0f;

  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
  --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
}

A few things worth noting here. The dark background is #0f0f0f, not pure #000000. Pure black creates too much contrast with text and causes visual fatigue. On OLED screens, pure black also creates a "smearing" effect during scrolling because pixels turn completely off and have a slight delay turning back on. A very dark gray like #0f0f0f or #121212 avoids both problems.

The accent color shifts from #2563eb (blue-700) to #60a5fa (blue-400). Darker blues do not have enough contrast against dark backgrounds. You need to bump your accent colors lighter by 2-3 steps in the color scale.

The prefers-color-scheme Media Query

Before building any toggle UI, you should respect the user's operating system preference. The prefers-color-scheme media query lets you detect whether someone has enabled dark mode at the OS level.

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-bg-primary: #0f0f0f;
    --color-bg-secondary: #1a1a1a;
    /* ... all dark overrides ... */
  }
}

That selector (:root:not([data-theme="light"])) is the key. It means: apply dark mode when the OS prefers it, UNLESS the user has explicitly chosen light mode on this site. This creates a three-state system: auto (follow OS), forced light, and forced dark.

The priority order works out to: explicit user choice beats OS preference, which beats the default light theme.

Building the Theme Switcher

The toggle needs to do three things: switch the visual theme immediately, save the preference to localStorage, and load the saved preference before the page renders to prevent flash of wrong theme (FOWT).

class ThemeSwitcher {
  constructor() {
    this.STORAGE_KEY = 'jekcms-theme';
    this.theme = this.getSavedTheme();
    this.applyTheme(this.theme);
    this.bindToggle();
  }

  getSavedTheme() {
    const saved = localStorage.getItem(this.STORAGE_KEY);
    if (saved === 'light' || saved === 'dark') return saved;
    return 'auto';
  }

  getEffectiveTheme() {
    if (this.theme !== 'auto') return this.theme;
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark' : 'light';
  }

  applyTheme(theme) {
    this.theme = theme;
    const effective = this.getEffectiveTheme();
    document.documentElement.setAttribute('data-theme', effective);
    localStorage.setItem(this.STORAGE_KEY, theme);
    this.updateToggleIcon(effective);
  }

  cycle() {
    const order = ['auto', 'light', 'dark'];
    const current = order.indexOf(this.theme);
    const next = order[(current + 1) % order.length];
    this.applyTheme(next);
  }

  bindToggle() {
    const btn = document.querySelector('[data-theme-toggle]');
    if (!btn) return;
    btn.addEventListener('click', () => this.cycle());

    window.matchMedia('(prefers-color-scheme: dark)')
      .addEventListener('change', () => {
        if (this.theme === 'auto') {
          this.applyTheme('auto');
        }
      });
  }

  updateToggleIcon(effective) {
    const btn = document.querySelector('[data-theme-toggle]');
    if (!btn) return;
    const icons = { auto: '◑', light: '☀', dark: '☾' };
    const labels = { auto: 'Auto', light: 'Light', dark: 'Dark' };
    btn.textContent = icons[this.theme];
    btn.setAttribute('aria-label', 'Theme: ' + labels[this.theme]);
  }
}

document.addEventListener('DOMContentLoaded', () => new ThemeSwitcher());

The three-state cycle (auto, light, dark) is better than a binary toggle because it lets users go back to "follow my OS" without having to remember what their OS is set to.

Preventing Flash of Wrong Theme

The biggest dark mode UX problem is the flash of white that appears before JavaScript runs. If someone prefers dark mode and your page loads white for a moment before switching, that flash is jarring: especially at night.

The fix is a tiny inline script in the <head>, before any stylesheet loads:

<script>
(function() {
  var saved = localStorage.getItem('jekcms-theme');
  var theme = saved || 'auto';
  var effective = theme;
  if (theme === 'auto') {
    effective = window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark' : 'light';
  }
  document.documentElement.setAttribute('data-theme', effective);
})();
</script>

This runs synchronously before the browser paints anything, so the correct theme is applied from the very first frame. It is one of the few cases where inline JavaScript in the head is the right approach.

Transition Animations

A sudden color swap looks broken. A smooth transition feels intentional. Apply a transition to all elements that use theme colors:

*,
*::before,
*::after {
  transition: var(--transition-colors);
}

This is the simple approach and it works fine for most sites. The 0.2s duration is short enough to feel snappy but long enough to be visible. However, this wildcard transition has a cost: it applies to every element on the page, including ones that will never change color.

For performance-critical pages, target specific elements instead:

body,
.header,
.sidebar,
.card,
.btn,
input,
textarea,
select,
pre,
code {
  transition: var(--transition-colors);
}

On pages with a lot of elements this keeps the transition work limited to the containers that actually change colour.

Image Handling in Dark Mode

Images are the easiest part of dark mode to get wrong. There are several categories of images, and each needs different treatment.

Photographs

Regular photos should not be modified. Do not apply any filter or opacity change to photography. A photo of a sunset should look the same in both modes. The only thing you might do is add a subtle border or shadow so the image edges are distinguishable from a dark background:

[data-theme="dark"] .post-image img {
  box-shadow: 0 0 0 1px var(--color-border-subtle);
}

Diagrams and Screenshots

White-background diagrams and screenshots look like glowing rectangles in dark mode. The best solution is to provide separate versions (a light diagram and a dark diagram) using the <picture> element:

<picture>
  <source srcset="diagram-dark.avif"
          media="(prefers-color-scheme: dark)"
          type="image/avif">
  <source srcset="diagram-light.avif" type="image/avif">
  <img src="diagram-light.png" alt="Architecture diagram">
</picture>

When separate versions are not available, a CSS filter can reduce the brightness without inverting colors:

[data-theme="dark"] .diagram img {
  filter: brightness(0.85) contrast(1.1);
}

Logos and Icons

SVG icons that use currentColor adapt automatically. For raster logos with transparent backgrounds, you might need a light version for dark backgrounds. For logos on solid backgrounds, the same brightness reduction works:

[data-theme="dark"] .partner-logo img {
  filter: brightness(0.9);
  opacity: 0.85;
}

Decorative Background Images

Background images used for texture or atmosphere often need an overlay in dark mode:

.hero {
  position: relative;
}

[data-theme="dark"] .hero::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  pointer-events: none;
}

Syntax Highlighting Themes

If your site includes code blocks (and any CMS blog will) you need matching syntax highlighting themes.

/* Light theme syntax colors */
:root {
  --code-bg: #f6f8fa;
  --code-text: #24292e;
  --code-keyword: #d73a49;
  --code-string: #032f62;
  --code-comment: #6a737d;
  --code-function: #6f42c1;
  --code-number: #005cc5;
  --code-operator: #d73a49;
}

/* Dark theme syntax colors */
[data-theme="dark"] {
  --code-bg: #161b22;
  --code-text: #c9d1d9;
  --code-keyword: #ff7b72;
  --code-string: #a5d6ff;
  --code-comment: #8b949e;
  --code-function: #d2a8ff;
  --code-number: #79c0ff;
  --code-operator: #ff7b72;
}

pre, code {
  background: var(--code-bg);
  color: var(--code-text);
}

.token-keyword { color: var(--code-keyword); }
.token-string { color: var(--code-string); }
.token-comment { color: var(--code-comment); font-style: italic; }
.token-function { color: var(--code-function); }
.token-number { color: var(--code-number); }

Form Element Styling

Form elements are the most frustrating part of dark mode because browsers apply their own styling. Inputs, textareas, and selects need explicit dark mode styles or they will remain white.

input,
textarea,
select {
  background-color: var(--color-bg-elevated);
  color: var(--color-text-primary);
  border: 1px solid var(--color-border-default);
}

input::placeholder,
textarea::placeholder {
  color: var(--color-text-tertiary);
}

/* Fix autofill background in dark mode */
[data-theme="dark"] input:-webkit-autofill,
[data-theme="dark"] input:-webkit-autofill:hover,
[data-theme="dark"] input:-webkit-autofill:focus {
  -webkit-text-fill-color: var(--color-text-primary);
  -webkit-box-shadow: 0 0 0px 1000px var(--color-bg-elevated) inset;
  transition: background-color 5000s ease-in-out 0s;
}

That autofill hack is ugly but necessary. Chrome's autofill applies an opaque yellow background that you cannot override with normal CSS. The box-shadow trick covers it with your theme color, and the absurdly long transition duration prevents Chrome from animating back to yellow.

Custom Select Dropdowns

Native <select> dropdowns inherit some styles from the OS and ignore your CSS in certain browsers. If your design requires consistent dropdown styling across light and dark modes, you will need a custom dropdown component. For most content sites, the native select with the background/color overrides above is good enough.

Testing Across Browsers

Dark mode behaves a little differently in each browser. Things to check:

  • Chrome/Edge (Chromium): prefers-color-scheme follows the OS setting. Autofill backgrounds need the box-shadow hack. Custom scrollbar colors work via ::-webkit-scrollbar.
  • Firefox: Has its own dark mode override that can force dark colors on pages that do not support it. Your explicit dark mode implementation prevents Firefox from applying its own. Scrollbar colors use the standard scrollbar-color property.
  • Safari: Respects prefers-color-scheme on macOS and iOS. The color-scheme CSS property is particularly important here: Safari uses it to determine scrollbar appearance, form control colors, and the default background during page load.

Add this to both your light and dark themes:

:root {
  color-scheme: light;
}

[data-theme="dark"] {
  color-scheme: dark;
}

The color-scheme property tells the browser which color schemes your page supports. In Safari, this changes the default background color during page load (preventing white flash), adjusts scrollbar appearance, and modifies native form control colors. It is a single line that solves multiple problems simultaneously.

Scrollbar Styling

A bright scrollbar on a dark page is a dead giveaway that dark mode was an afterthought.

/* Firefox */
* {
  scrollbar-color: var(--color-border-default) var(--color-bg-secondary);
  scrollbar-width: thin;
}

/* Chromium */
::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}

::-webkit-scrollbar-track {
  background: var(--color-bg-secondary);
}

::-webkit-scrollbar-thumb {
  background: var(--color-border-default);
}

::-webkit-scrollbar-thumb:hover {
  background: var(--color-text-tertiary);
}

Common Mistakes

Mistake 1: Using opacity instead of separate colors

Darkening elements by lowering their opacity is tempting, but it makes text hard to read because it reduces the contrast ratio. Always use distinct color values for each theme.

Mistake 2: Forgetting box-shadow colors

Shadows with rgba(0,0,0,0.1) are invisible on dark backgrounds. Dark mode shadows need higher opacity values (typically 0.3 to 0.5) to be visible against dark surfaces. This is why the shadow values are included in the custom properties.

Mistake 3: Not testing with actual content

Dark mode with placeholder text looks fine. Dark mode with a 2000-word article that includes embedded tweets, YouTube videos, code blocks, and a mix of inline images reveals all the edge cases. Test with your heaviest content pages first.

Mistake 4: Ignoring third-party embeds

YouTube embeds, Google Maps, social media widgets: none of these respect your dark mode. The best you can do is wrap them in a container and reduce brightness slightly:

[data-theme="dark"] .embed-container iframe {
  filter: brightness(0.9);
}

The Complete Toggle Button HTML

<button
  data-theme-toggle
  type="button"
  class="theme-toggle"
  aria-label="Toggle theme"
  title="Toggle theme">
  ◑
</button>

<style>
.theme-toggle {
  position: fixed;
  bottom: 1rem;
  right: 1rem;
  width: 40px;
  height: 40px;
  border: 1px solid var(--color-border-default);
  background: var(--color-bg-elevated);
  color: var(--color-text-primary);
  font-size: 1.2rem;
  cursor: pointer;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: var(--transition-colors);
}

.theme-toggle:hover {
  border-color: var(--color-accent);
  color: var(--color-accent);
}
</style>

The toggle sits at the bottom-right corner where it is accessible but not distracting. The fixed positioning means it is always available during scrolling.

Performance

The CSS custom properties approach means the browser only needs to recompute property values when the theme changes: it does not need to re-evaluate selector matches or recalculate specificity. This is why custom properties are fundamentally faster for theming than class-based approaches that swap dozens of selectors.

Accessibility Considerations

Dark mode is not just a visual preference: it is an accessibility feature for people with light sensitivity, migraines, and certain visual impairments. Ensure your dark theme maintains WCAG AA contrast ratios (4.5:1 for normal text, 3:1 for large text).

Use a contrast checker such as WebAIM during development and test every text and background pair in both themes.

The theme toggle must be keyboard accessible (it is, since we used a <button> element) and must announce its state to screen readers via aria-label. When the theme changes, the label updates to reflect the current mode.

Written by

Celil Uyanıkoğlu

Computer engineer with 25+ years in IT. He builds jekcms and runs his own network of content sites on it - every guide published here is tried on those live installs first.

See all posts →

Choose a Licence

Annual licence with a discounted first year; updates and support included. Setup in 30 minutes.

View Pricing
  • Setup and live in 30 minutes
  • 14 professional themes
  • AVIF/WebP image optimization
  • Automatic SEO - Sitemap, Schema.org
  • ZeroTrack cookieless analytics

Be the first to know

New features, release notes and CMS guides. We send a couple of emails a month.