Dark Mode in Tailwind: Beyond Just Swapping Colors

Bryan Heath Bryan Heath
· · 12 min read

Most dark mode implementations start and end with swapping background and text colors. White becomes dark gray, black text becomes white, and the developer calls it done. But if you've shipped a real dark mode, you know the problems start after the color swap: images look wrong, shadows vanish, borders are either invisible or blinding, and the overall contrast feels off. This guide covers the techniques that separate a polished dark mode from a lazy one, using Tailwind CSS v4 and CSS custom properties.

Setting Up Dark Mode in Tailwind v4

Tailwind v4 supports dark mode out of the box using the dark variant. By default, it uses the prefers-color-scheme media query, which means it follows the operating system setting. If you want class-based toggling instead, you configure a custom variant in your CSS file.

Here's the media query approach — no configuration needed:

<div class="bg-white dark:bg-gray-900">
  <h1 class="text-gray-900 dark:text-white">Hello, dark mode</h1>
</div>

For class-based toggling, where you control the theme with JavaScript, add a custom variant in your CSS file using the @custom-variant directive:

@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

With this in place, adding a dark class to the <html> or <body> element activates every dark: utility on the page. A small Alpine.js component handles the toggle and persists the preference:

<button
  x-data="{ dark: localStorage.getItem('theme') === 'dark' }"
  x-init="$watch('dark', val => {
    localStorage.setItem('theme', val ? 'dark' : 'light');
    document.documentElement.classList.toggle('dark', val);
  });
  document.documentElement.classList.toggle('dark', dark);"
  @click="dark = !dark"
  class="rounded-lg p-2 text-gray-600 hover:bg-gray-100
         dark:text-gray-300 dark:hover:bg-gray-800"
>
  <span x-show="!dark">🌙</span>
  <span x-show="dark">☀️</span>
</button>

Using CSS Custom Properties for Semantic Theming

Sprinkling dark: variants on every single element is tedious and makes templates hard to read. A better approach is to define semantic color tokens as CSS custom properties and let them change based on the theme. Tailwind v4 makes this natural with the @theme directive.

@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

@theme {
  --color-surface: #ffffff;
  --color-surface-raised: #f9fafb;
  --color-surface-sunken: #f3f4f6;
  --color-on-surface: #111827;
  --color-on-surface-muted: #6b7280;
  --color-border: #e5e7eb;
  --color-border-strong: #d1d5db;
  --color-shadow: rgb(0 0 0 / 0.08);
}

.dark {
  --color-surface: #0f172a;
  --color-surface-raised: #1e293b;
  --color-surface-sunken: #020617;
  --color-on-surface: #f1f5f9;
  --color-on-surface-muted: #94a3b8;
  --color-border: #334155;
  --color-border-strong: #475569;
  --color-shadow: rgb(0 0 0 / 0.4);
}

Now your templates use semantic tokens that automatically adapt to the theme without any dark: variants:

<div class="bg-surface text-on-surface border border-border rounded-xl
            shadow-[0_1px_3px_var(--color-shadow)]">
  <h2 class="text-on-surface font-semibold">Card Title</h2>
  <p class="text-on-surface-muted">Supporting text that stays readable.</p>
</div>

The token names describe purpose, not appearance. surface means "the background of a content area." on-surface means "text sitting on that surface." This pattern scales to any number of themes, not just light and dark.

Shadows That Work in Dark Mode

Shadows are the first thing to break in dark mode. A shadow-lg that looks elegant on a white background is completely invisible on a dark one. And simply cranking up the opacity makes it look harsh. The trick is to adjust both the opacity and the spread of shadows in dark mode.

@import "tailwindcss";

@theme {
  --shadow-card: 0 1px 3px rgb(0 0 0 / 0.08), 0 1px 2px rgb(0 0 0 / 0.04);
  --shadow-card-hover: 0 4px 12px rgb(0 0 0 / 0.1), 0 2px 4px rgb(0 0 0 / 0.06);
}

.dark {
  --shadow-card: 0 1px 3px rgb(0 0 0 / 0.3), 0 1px 2px rgb(0 0 0 / 0.2);
  --shadow-card-hover: 0 4px 12px rgb(0 0 0 / 0.5), 0 2px 4px rgb(0 0 0 / 0.3);
}

Use these as utilities in your templates:

<article class="rounded-xl bg-surface-raised shadow-card
                hover:shadow-card-hover transition-shadow duration-200">
  <div class="p-6">
    <h3 class="text-on-surface font-semibold">Article title</h3>
    <p class="mt-2 text-on-surface-muted">Article excerpt goes here...</p>
  </div>
</article>

Dark backgrounds need stronger shadows to create the same sense of elevation. An opacity of 0.08 on light becomes 0.3 on dark — roughly three to four times the intensity. The shadow spread can stay the same.

Borders and Dividers

Borders serve two purposes: decoration and separation. In light mode, a light gray border is subtle and clean. In dark mode, the same gray against a dark background can either disappear entirely or create a jarring bright line. The key is to use border colors that are one step away from the surface, not a fixed gray.

<!-- Using semantic tokens: border-border adapts automatically -->
<div class="divide-y divide-border">
  <div class="py-4">
    <p class="text-on-surface">First item</p>
  </div>
  <div class="py-4">
    <p class="text-on-surface">Second item</p>
  </div>
  <div class="py-4">
    <p class="text-on-surface">Third item</p>
  </div>
</div>

<!-- For stronger separation, use border-strong -->
<div class="border border-border-strong rounded-lg p-4">
  <p class="text-on-surface">A bordered container with stronger edges</p>
</div>

Having two border tokens — border for subtle separators and border-strong for container outlines — gives you flexibility without cluttering your markup with dark: variants.

Handling Images and Media

Images designed for light backgrounds look jarring on dark ones. Screenshots with white backgrounds create bright rectangles. Logos with transparent backgrounds and dark text become invisible. There are several techniques to handle this gracefully.

Reducing Brightness and Contrast

For photographic content, slightly reducing brightness in dark mode prevents the image from being an eye-searing bright spot on the page:

<img
  src="/images/hero.jpg"
  alt="Hero image"
  class="rounded-xl dark:brightness-90 dark:contrast-[1.05]
         transition-[filter] duration-200"
>

The brightness-90 dims the image by 10%, and contrast-[1.05] nudges the contrast up slightly to compensate. The effect is subtle but prevents the image from feeling washed out while still reducing its visual intensity.

Swapping Images Entirely

For logos and illustrations that have theme-specific versions, use the <picture> element with prefers-color-scheme, or conditionally show elements with Tailwind:

<!-- Using the picture element for media-query dark mode -->
<picture>
  <source srcset="/images/logo-light.svg" media="(prefers-color-scheme: dark)">
  <img src="/images/logo-dark.svg" alt="Company logo">
</picture>

<!-- Using Tailwind classes for class-based dark mode -->
<img src="/images/logo-dark.svg" alt="Company logo" class="block dark:hidden">
<img src="/images/logo-light.svg" alt="Company logo" class="hidden dark:block">

Taming Screenshots and White-Background Content

Screenshots of light-themed UIs embedded in dark mode content create a blinding white rectangle. Add a subtle border and slight inset to contain them:

<figure class="my-8">
  <div class="overflow-hidden rounded-xl border border-border
              dark:ring-1 dark:ring-white/10">
    <img
      src="/images/screenshot.png"
      alt="Application screenshot"
      class="w-full dark:brightness-90"
    >
  </div>
  <figcaption class="mt-3 text-center text-sm text-on-surface-muted">
    The dashboard overview showing key metrics.
  </figcaption>
</figure>

The ring-1 ring-white/10 adds a faint inner glow in dark mode that visually separates the screenshot from the background without a harsh border.

Contrast and Typography

The most common dark mode mistake is insufficient contrast between text and background. WCAG 2.1 requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text. These ratios matter even more in dark mode because screens emit light directly and low-contrast text causes more eye strain.

Avoid pure white text on pure black backgrounds. The extreme contrast of #ffffff on #000000 causes halation — a visual effect where bright white text appears to bleed into the dark background. Use slightly off-white for text and dark gray for backgrounds instead:

<!-- Avoid this: too much contrast -->
<div class="bg-black text-white">
  <p>This text causes eye strain in dark environments.</p>
</div>

<!-- Better: softer contrast that is still highly readable -->
<div class="bg-gray-950 text-gray-100">
  <p>This text is comfortable to read for extended periods.</p>
</div>

<!-- Best: use your semantic tokens -->
<div class="bg-surface text-on-surface">
  <p>Semantic tokens give you a single place to tune contrast.</p>
</div>

For muted or secondary text, be careful not to drop below the 4.5:1 ratio. A common mistake is using text-gray-500 for secondary text in dark mode. On a bg-gray-900 background, text-gray-500 only achieves about 3.4:1, which fails WCAG AA for normal text. Use text-gray-400 or lighter to stay accessible.

Interactive States in Dark Mode

Hover, focus, and active states need separate attention in dark mode. A hover state that lightens a gray-100 button to white in light mode doesn't translate to dark mode — you can't lighten a dark button the same way because the visual range is different.

<!-- A button with proper light and dark interactive states -->
<button class="rounded-lg bg-gray-100 px-4 py-2 text-gray-700
               hover:bg-gray-200 active:bg-gray-300
               dark:bg-gray-800 dark:text-gray-200
               dark:hover:bg-gray-700 dark:active:bg-gray-600
               transition-colors duration-150">
  Secondary Action
</button>

<!-- A primary button where the brand color works in both modes -->
<button class="rounded-lg bg-mint-500 px-4 py-2 font-medium text-white
               hover:bg-mint-600 active:bg-mint-700
               dark:bg-mint-600 dark:hover:bg-mint-500 dark:active:bg-mint-400
               transition-colors duration-150">
  Primary Action
</button>

Notice the inversion pattern for the primary button: in light mode, hover goes darker (500 to 600). In dark mode, hover goes lighter (600 to 500). This feels natural because in light mode you darken to indicate depth, while in dark mode you lighten toward the light source.

Building a Complete Dark Mode Card

Here's a card component that demonstrates all these principles working together — semantic tokens, proper shadows, image handling, border treatment, and interactive states:

<article class="group overflow-hidden rounded-xl border border-border
                bg-surface-raised shadow-card
                hover:shadow-card-hover transition-all duration-200">
  <!-- Image with dark mode brightness adjustment -->
  <div class="aspect-video overflow-hidden">
    <img
      src="/images/post-cover.jpg"
      alt="Blog post cover image"
      class="h-full w-full object-cover transition-all duration-300
             group-hover:scale-105 dark:brightness-90"
    >
  </div>

  <!-- Content area -->
  <div class="p-6">
    <!-- Category badge -->
    <span class="inline-block rounded-full bg-mint-500/10 px-3 py-1
                 text-xs font-medium text-mint-700
                 dark:bg-mint-400/10 dark:text-mint-300">
      Tailwind CSS
    </span>

    <h3 class="mt-3 text-lg font-semibold text-on-surface
               group-hover:text-mint-600 dark:group-hover:text-mint-400
               transition-colors duration-200">
      Article Title Goes Here
    </h3>

    <p class="mt-2 text-sm leading-relaxed text-on-surface-muted">
      A brief description of the article content that gives readers
      enough context to decide whether to click through.
    </p>

    <!-- Footer with metadata -->
    <div class="mt-4 flex items-center gap-3 border-t border-border pt-4">
      <img
        src="/images/avatar.jpg"
        alt="Author"
        class="h-8 w-8 rounded-full ring-2 ring-surface"
      >
      <div>
        <p class="text-sm font-medium text-on-surface">Author Name</p>
        <p class="text-xs text-on-surface-muted">March 12, 2026</p>
      </div>
    </div>
  </div>
</article>

Preventing Flash of Wrong Theme

If you use class-based dark mode, there's a common problem: when the page loads, it briefly shows the light theme before JavaScript runs and applies the dark class. This flash is distracting and unprofessional. The fix is a tiny blocking script in the <head> that runs before any rendering happens:

<head>
  <script>
    (function() {
      const theme = localStorage.getItem('theme');
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      if (theme === 'dark' || (!theme && prefersDark)) {
        document.documentElement.classList.add('dark');
      }
    })();
  </script>
  <!-- Rest of your head content -->
</head>

This script is intentionally synchronous and tiny. It reads from localStorage, checks the system preference as a fallback, and applies the class before the browser paints anything. No flash, no layout shift.

Code Blocks and Syntax Highlighting

If your site displays code, syntax-highlighted blocks need their own dark mode treatment. Most syntax highlighting themes come in both light and dark variants. The approach depends on your highlighting tool, but the CSS pattern is the same — swap theme variables based on the mode:

/* Light mode code block */
:root {
  --code-bg: #f8fafc;
  --code-text: #334155;
  --code-keyword: #7c3aed;
  --code-string: #059669;
  --code-comment: #94a3b8;
  --code-border: #e2e8f0;
}

/* Dark mode code block */
.dark {
  --code-bg: #1e293b;
  --code-text: #e2e8f0;
  --code-keyword: #a78bfa;
  --code-string: #34d399;
  --code-comment: #64748b;
  --code-border: #334155;
}
<pre class="rounded-xl border p-4"
     style="background: var(--code-bg);
            color: var(--code-text);
            border-color: var(--code-border);">
  <code>Your highlighted code here</code>
</pre>

Note that the keyword and string colors shift to lighter, more saturated versions in dark mode. Dark backgrounds need brighter accent colors to achieve the same visual distinction.

Form Inputs in Dark Mode

Form inputs are another area where dark mode often falls apart. The default browser styling for inputs is designed for light backgrounds. In dark mode, inputs with default backgrounds create bright white rectangles that break the visual flow.

<div class="space-y-4">
  <div>
    <label class="block text-sm font-medium text-on-surface mb-1">
      Email address
    </label>
    <input
      type="email"
      class="w-full rounded-lg border border-border bg-surface px-4 py-2.5
             text-on-surface placeholder:text-on-surface-muted/60
             focus:border-mint-500 focus:outline-none focus:ring-2
             focus:ring-mint-500/20
             dark:focus:ring-mint-400/20 dark:focus:border-mint-400
             transition-colors duration-150"
      placeholder="you@example.com"
    >
  </div>

  <div>
    <label class="block text-sm font-medium text-on-surface mb-1">
      Message
    </label>
    <textarea
      rows="4"
      class="w-full rounded-lg border border-border bg-surface px-4 py-2.5
             text-on-surface placeholder:text-on-surface-muted/60
             focus:border-mint-500 focus:outline-none focus:ring-2
             focus:ring-mint-500/20
             dark:focus:ring-mint-400/20 dark:focus:border-mint-400
             transition-colors duration-150"
      placeholder="Write your message..."
    ></textarea>
  </div>
</div>

The semantic bg-surface and border-border tokens handle the base styling automatically. The focus ring shifts from mint-500 to mint-400 in dark mode for better visibility against the dark background.

Testing Your Dark Mode

Before shipping, run through this checklist for every page:

Check every text element against its background for contrast ratio. Browser DevTools can report contrast ratios in the color picker. Firefox DevTools also has an accessibility panel that flags contrast violations across the entire page.

Verify shadows and elevation. Cards, modals, and dropdowns should still feel "raised" from the background. If shadows are invisible, increase the opacity for dark mode.

Test images and media. Scroll through every page and look for bright rectangles caused by images with white backgrounds. Apply brightness filters or swap images where needed.

Test form interactions. Fill in every form field and tab through inputs. Focus rings and validation states should be clearly visible in both modes.

Toggle rapidly between themes. Transitions should be smooth. If you see elements that "pop" between states without transitioning, add transition-colors to them.

Conclusion

A good dark mode isn't the inverse of your light mode — it's a parallel design system that shares structure but has its own visual rules. Shadows need more intensity, borders need careful tuning, images need brightness adjustments, and contrast ratios need individual verification. The color swap is the easy part. Everything else is where the quality shows.

Tailwind v4 and CSS custom properties give you powerful tools to manage this complexity. Define semantic tokens in @theme, override them in a .dark selector, and your templates stay clean while supporting a fully polished dark experience. Start with the token system, then work through shadows, borders, images, and interactive states one at a time. I've gone through this process on three projects now, and every time the dark mode went from "passable" to something users actually preferred.

Share:

Related Posts