Back to blog

Dark Mode React Components: Patterns & Best Practices

Published on March 20, 2026·5 min read

Dark mode is not a nice-to-have — it is expected. Users toggle between light and dark based on ambient light, personal preference, and eye strain. A landing page that ignores dark mode looks broken on half your visitors' screens. This guide covers the patterns and implementation details for building React components that work flawlessly in both themes.

The CSS Custom Properties Approach

The foundation of any theme system is CSS custom properties (variables). Define your color tokens once, then swap their values based on the active theme:

/* globals.css */
:root {
  --color-background: #ffffff;
  --color-foreground: #0a0a0a;
  --color-card: #ffffff;
  --color-border: #e5e5e5;
  --color-muted: #737373;
  --color-primary: #2563eb;
  --color-primary-foreground: #ffffff;
}

[data-theme="dark"] {
  --color-background: #0a0a0a;
  --color-foreground: #fafafa;
  --color-card: #171717;
  --color-border: #262626;
  --color-muted: #a3a3a3;
  --color-primary: #3b82f6;
  --color-primary-foreground: #ffffff;
}

Components reference these tokens — never hardcoded hex values:

<section
  style={{
    background: "var(--color-background)",
    color: "var(--color-foreground)",
  }}
>
  <div style={{ borderColor: "var(--color-border)" }}>
    {/* content */}
  </div>
</section>

When the data-theme attribute changes on the root element, every component updates simultaneously. No prop drilling, no context re-renders — pure CSS cascade.

Tailwind CSS Dark Mode

Tailwind provides the dark: variant prefix. In Tailwind v4, dark mode uses the prefers-color-scheme media query by default. To use class-based toggling (recommended for user-controlled themes), configure the selector in your CSS:

/* app.css — Tailwind v4 */
@import "tailwindcss";

@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

Now you can use dark: utilities anywhere:

export function FeatureCard({ title, description }: { title: string; description: string }) {
  return (
    <div className="rounded-2xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
      <h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
        {title}
      </h3>
      <p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
        {description}
      </p>
    </div>
  );
}

The dark: prefix is clean and co-located with the component markup. No separate stylesheets, no CSS-in-JS runtime cost.

Theme Toggle with Smooth Transition

A theme toggle needs to (1) update the DOM attribute, (2) persist the preference, and (3) avoid a flash of wrong theme on page load. Here is a complete implementation:

"use client";

import { useEffect, useState } from "react";
import { motion } from "motion/react";

type Theme = "light" | "dark";

function getStoredTheme(): Theme {
  if (typeof window === "undefined") return "light";
  return (localStorage.getItem("theme") as Theme) ?? "light";
}

export function ThemeToggle() {
  const [theme, setTheme] = useState<Theme>("light");

  useEffect(() => {
    const stored = getStoredTheme();
    setTheme(stored);
    document.documentElement.setAttribute("data-theme", stored);
  }, []);

  function toggle() {
    const next = theme === "light" ? "dark" : "light";
    setTheme(next);
    document.documentElement.setAttribute("data-theme", next);
    localStorage.setItem("theme", next);
  }

  return (
    <button
      onClick={toggle}
      className="relative h-8 w-14 rounded-full bg-neutral-200 dark:bg-neutral-700"
      aria-label="Toggle theme"
    >
      <motion.div
        className="absolute top-1 left-1 h-6 w-6 rounded-full bg-white shadow-sm"
        animate={{ x: theme === "dark" ? 24 : 0 }}
        transition={{ type: "spring", stiffness: 500, damping: 30 }}
      />
    </button>
  );
}

To prevent the flash of wrong theme, add a blocking inline script in your root layout's <head>. This script reads the stored theme from localStorage and sets the data-theme attribute synchronously before React hydrates:

// app/layout.tsx — inside <head>
<script>{`
  (function() {
    var theme = localStorage.getItem('theme') || 'light';
    document.documentElement.setAttribute('data-theme', theme);
  })();
`}</script>

This runs before the first paint, so the page renders with the correct theme from the start.

Dark-First Design

Most developers design in light mode first, then bolt on dark mode as an afterthought. The result: dark mode looks washed out, low-contrast, or has forgotten elements with white backgrounds.

A better approach: design dark mode first. Dark UIs expose contrast problems immediately. If a component looks good on a dark background, it almost certainly works in light mode with minor adjustments. The reverse is not true.

Practical rules for dark-first design:

  • Borders over shadows. Shadows are nearly invisible on dark backgrounds. Use borders (border-neutral-800) to define card edges in dark mode, then optionally add shadows for light mode.
  • Muted backgrounds for depth. Instead of shadows, use slightly lighter background shades to create elevation: bg-neutral-900 for the page, bg-neutral-800 for cards, bg-neutral-700 for elevated elements.
  • Avoid pure black. #000000 backgrounds create excessive contrast with white text, causing eye strain. Use #0a0a0a or #111111 instead.
  • Test at 3:1 minimum contrast. WCAG AA requires 4.5:1 for body text and 3:1 for large text. Dark mode palettes commonly fail this — check with browser DevTools' accessibility panel.

Contrast Ratios

The most common dark mode bug is insufficient contrast. Here is a quick reference for neutral text on dark backgrounds:

| Background | Text Color | Contrast Ratio | WCAG AA | |-----------|-----------|---------------|---------| | #0a0a0a | #fafafa | 19.3:1 | Pass | | #0a0a0a | #a3a3a3 | 7.2:1 | Pass | | #0a0a0a | #737373 | 4.2:1 | Pass (large text) | | #0a0a0a | #525252 | 2.6:1 | Fail | | #171717 | #a3a3a3 | 6.3:1 | Pass | | #171717 | #737373 | 3.7:1 | Pass (large text) |

Use #a3a3a3 or lighter for body text on dark backgrounds. Reserve #737373 for labels, captions, and other secondary text in large sizes only.

Component Patterns

When building dark-mode-ready section components, follow these patterns:

  1. Use semantic token names (--color-foreground) not raw colors (#0a0a0a). This makes theming automatic.
  2. Never hardcode bg-white — use bg-card or bg-background tokens that swap in dark mode.
  3. Images and illustrations need dark variants or transparent backgrounds. A white-background PNG on a dark page looks broken.
  4. Code blocks should use a syntax theme that works on both backgrounds, or switch themes with the toggle.

Ready-Made Dark Mode Sections

The Incubator catalog ships 844+ React sections built with a CSS token system that supports light and dark mode out of the box. Every section — from hero blocks to pricing tables to feature grids — uses semantic color tokens that respond to theme changes instantly. Browse the catalog, toggle dark mode, and copy the sections that fit your design.

VA

Victor Aubague

Developer & creator of Incubator

Full-stack developer specialized in React, Next.js, and TypeScript. I built Incubator to help developers ship beautiful interfaces faster — all components are crafted from real client projects and production code.

LinkedIn
Dark Mode React Components: Patterns & Best Practices — Incubator