CSS

How to Build Dark Mode with Pure CSS

dark-mode-css

I'm writing this at 11 PM with my monitor set to dark mode. My code editor is dark. My browser is dark. My terminal is dark. And if I stumble onto a website that blasts me with a white background, I instinctively close the tab before my retinas recover.

Dark mode isn't just a trend — it's become a baseline expectation. Users notice when a site doesn't have it. And the best part? You can implement it with pure CSS custom properties and about 30 lines of JavaScript for the toggle. No libraries, no dependencies, no excuses.

Let me show you exactly how I build dark mode for every project.

Step 1: Detect the User's System Preference

Before building any toggle, start by respecting what the user has already chosen at the OS level. The prefers-color-scheme media query makes this trivial:

/* Light mode (default) */
:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f8fafc;
  --text-primary: #0f172a;
  --text-secondary: #64748b;
  --border: #e2e8f0;
  --accent: #3b82f6;
}

/* Dark mode — activated by OS preference */
@media (prefers-color-scheme: dark) {
  :root {
    --bg-primary: #0f172a;
    --bg-secondary: #1e293b;
    --text-primary: #f1f5f9;
    --text-secondary: #94a3b8;
    --border: #334155;
    --accent: #60a5fa;
  }
}

That's it. If a user has dark mode enabled on their device, your site automatically switches. Zero JavaScript required.

Now use those custom properties throughout your CSS instead of hard-coded color values:

body {
  background: var(--bg-primary);
  color: var(--text-primary);
}

.card {
  background: var(--bg-secondary);
  border: 1px solid var(--border);
}

a {
  color: var(--accent);
}

.text-muted {
  color: var(--text-secondary);
}

Every color in your entire site now responds to the user's preference through a single set of variables. Change the variables, everything updates.

Step 2: Add a Manual Toggle

System detection is great, but users also want to override it. Maybe they prefer dark mode everywhere except your site, or vice versa. A manual toggle gives them that control.

The approach I prefer: use a data-theme attribute on the <html> element.

/* Default: light theme */
:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f8fafc;
  --text-primary: #0f172a;
  --text-secondary: #64748b;
  --border: #e2e8f0;
  --accent: #3b82f6;
}

/* Dark theme — triggered by attribute */
[data-theme="dark"] {
  --bg-primary: #0f172a;
  --bg-secondary: #1e293b;
  --text-primary: #f1f5f9;
  --text-secondary: #94a3b8;
  --border: #334155;
  --accent: #60a5fa;
}

/* Still respect OS preference when no override */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg-primary: #0f172a;
    --bg-secondary: #1e293b;
    --text-primary: #f1f5f9;
    --text-secondary: #94a3b8;
    --border: #334155;
    --accent: #60a5fa;
  }
}

The CSS priority works perfectly here. If the user has set a data-theme attribute via the toggle, that takes precedence. If they haven't, the @media query respects their OS setting. And if their OS is on light mode with no toggle interaction, they get light mode.

The JavaScript Toggle

Here's the minimal JavaScript you need. It's about 20 lines:

const toggle = document.querySelector('#theme-toggle');
const html = document.documentElement;

// Check for saved preference, fallback to system
function getTheme() {
  const saved = localStorage.getItem('theme');
  if (saved) return saved;

  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light';
}

// Apply theme
function setTheme(theme) {
  html.setAttribute('data-theme', theme);
  localStorage.setItem('theme', theme);
  toggle.setAttribute('aria-label',
    `Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`
  );
}

// Initialize
setTheme(getTheme());

// Toggle on click
toggle.addEventListener('click', () => {
  const current = html.getAttribute('data-theme');
  setTheme(current === 'dark' ? 'light' : 'dark');
});

The key details: we save the preference to localStorage so it persists across page loads, and we update the aria-label on the toggle button for screen reader users.

Prevent the flash: If you apply the theme after the page loads, users will see a flash of the wrong theme. To prevent this, add a tiny inline script in the <head> (before any CSS loads): <script>document.documentElement.setAttribute('data-theme', localStorage.getItem('theme') || (matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light'))</script>

Step 3: Building the Toggle Button

The toggle itself can be as simple or as fancy as you want. Here's a clean, accessible version with a sun/moon icon swap:

<button id="theme-toggle" aria-label="Switch to dark mode">
  <svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24"
       fill="none" stroke="currentColor" stroke-width="2">
    <circle cx="12" cy="12" r="5"/>
    <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
  </svg>
  <svg class="moon-icon" width="20" height="20" viewBox="0 0 24 24"
       fill="none" stroke="currentColor" stroke-width="2">
    <path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
  </svg>
</button>

And the CSS to show/hide the right icon:

#theme-toggle {
  background: none;
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 8px;
  cursor: pointer;
  color: var(--text-primary);
  display: flex;
  align-items: center;
}

.moon-icon { display: none; }
[data-theme="dark"] .sun-icon { display: none; }
[data-theme="dark"] .moon-icon { display: block; }

Choosing Your Dark Mode Colors

This is where most dark mode implementations fall apart. The instinct is to just invert everything — white becomes black, black becomes white. Don't do that. It looks terrible.

Backgrounds: Don't Use Pure Black

Pure black (#000000) on screens creates too much contrast with text and makes everything feel harsh. It also causes a "smearing" effect on OLED screens when scrolling. Use a very dark gray instead.

/* Too harsh */
--bg-primary: #000000;

/* Much better */
--bg-primary: #0f172a;  /* Dark slate */
--bg-primary: #1a1a2e;  /* Deep navy */
--bg-primary: #171717;  /* Near-black neutral */

Text: Lower the Contrast Slightly

Pure white (#ffffff) on a dark background is like staring at headlights. Drop it down to a warm off-white:

/* Too bright */
--text-primary: #ffffff;

/* Easier on the eyes */
--text-primary: #f1f5f9;  /* Slate 100 */
--text-primary: #e2e8f0;  /* Slate 200 */

Use Multiple Background Levels

In light mode, you create depth with shadows. In dark mode, you create depth with lighter background shades. This is a critical design principle that many implementations get wrong:

/* Light mode: depth through shadows */
:root {
  --bg-primary: #ffffff;
  --bg-elevated: #ffffff;
  --shadow: 0 4px 12px rgba(0,0,0,0.1);
}

/* Dark mode: depth through lightness */
[data-theme="dark"] {
  --bg-primary: #0f172a;
  --bg-elevated: #1e293b;  /* Lighter = higher elevation */
  --shadow: 0 4px 12px rgba(0,0,0,0.3);
}

Cards, modals, and dropdowns should use --bg-elevated so they visually pop off the base background.

Don't forget images: Bright images look jarring against dark backgrounds. Consider adding a subtle brightness reduction in dark mode: [data-theme="dark"] img { filter: brightness(0.9); } — it's subtle but makes a noticeable difference in visual harmony.

Handling Edge Cases

Shadows in Dark Mode

Shadows that work in light mode become invisible in dark mode. You need darker, more opaque shadows:

:root {
  --shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
  --shadow-md: 0 4px 12px rgba(0,0,0,0.1);
}

[data-theme="dark"] {
  --shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
  --shadow-md: 0 4px 12px rgba(0,0,0,0.4);
}

Borders and Dividers

Light gray borders disappear against dark backgrounds. Define border colors as custom properties too:

:root {
  --border: #e2e8f0;
  --divider: #f1f5f9;
}

[data-theme="dark"] {
  --border: #334155;
  --divider: #1e293b;
}

Form Inputs

Inputs often have their own background color and may ignore your custom properties if you're not explicit:

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

/* Fix autocomplete styling in dark mode */
[data-theme="dark"] input:-webkit-autofill {
  -webkit-box-shadow: 0 0 0 1000px var(--bg-secondary) inset;
  -webkit-text-fill-color: var(--text-primary);
}

That autofill hack is ugly but necessary. Chrome's autofill applies its own yellow/blue background that completely ignores your theme.

The Complete Dark Mode Setup

Here's everything combined into a clean, copy-paste-ready setup:

/* === Theme Variables === */
:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f8fafc;
  --bg-elevated: #ffffff;
  --text-primary: #0f172a;
  --text-secondary: #64748b;
  --border: #e2e8f0;
  --accent: #3b82f6;
  --shadow: 0 4px 12px rgba(0,0,0,0.08);
}

[data-theme="dark"] {
  --bg-primary: #0f172a;
  --bg-secondary: #1e293b;
  --bg-elevated: #334155;
  --text-primary: #f1f5f9;
  --text-secondary: #94a3b8;
  --border: #475569;
  --accent: #60a5fa;
  --shadow: 0 4px 12px rgba(0,0,0,0.4);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg-primary: #0f172a;
    --bg-secondary: #1e293b;
    --bg-elevated: #334155;
    --text-primary: #f1f5f9;
    --text-secondary: #94a3b8;
    --border: #475569;
    --accent: #60a5fa;
    --shadow: 0 4px 12px rgba(0,0,0,0.4);
  }
}

/* === Smooth Transition === */
body {
  background: var(--bg-primary);
  color: var(--text-primary);
  transition: background 0.3s ease, color 0.3s ease;
}

That transition on the body gives a smooth crossfade when toggling themes instead of an instant snap. It's a small detail that makes the feature feel polished.

Preview Your Dark Mode Design

Built a beautiful dark mode theme? Export your HTML as a crisp image to share or compare light vs. dark versions.

Try HTML to PNG Free →

Common Dark Mode Mistakes

Wrapping Up

Dark mode used to be a nice-to-have feature. Now it's table stakes. Users expect it, and with CSS custom properties, implementing it properly takes less effort than most developers assume.

The approach is simple: define all your colors as CSS custom properties, swap them based on a data-theme attribute, respect prefers-color-scheme as a default, and persist the user's choice in localStorage. That's the entire architecture.

The devil is in the design details — choosing the right dark background, adjusting contrast ratios, handling images and shadows. But once you nail those, you'll have a dark mode that feels intentional, not like an afterthought.

Your users' eyeballs will thank you. Especially the ones reading your site at 11 PM.

Sachin Bhanushali
Written by

Sachin Bhanushali

Full-stack developer and creator of HTMLtoImages. Building free, privacy-first developer tools that run entirely in your browser.