OneMinuteBrandingOneMinuteBrandingGenerate Brand
  1. Home
  2. Blog
  3. Dark Mode Done Right: A Design System Approach
dark modedesign systemCSS

Dark Mode Done Right: A Design System Approach

Dark mode isn't inverting colors. It's a separate set of design tokens with different contrast rules. Here's how to implement it properly.

March 16, 20268 min readBy Yann Lephay

You just slapped dark:bg-slate-950 dark:text-white on your <body> tag and called it a day. Your users open your app at 2 AM, and the pure white text sears into their retinas like staring directly into a halogen headlight. You try dialing the text back to text-slate-200, but now your gray borders vanish into the background. Ten minutes later, you are playing CSS whack-a-mole across 40 different React components, appending dark:border-slate-800 to random divs until you lose the will to ship.

The Color Inversion Fallacy

Developers treat dark mode as a math problem. You assume that if a light mode background is slate-50 and the text is slate-900, the dark mode equivalent is simply flipping the scale: background slate-950, text slate-50.

This 1:1 inversion breaks your UI for a specific physical reason: depth is communicated differently in dark environments.

In light mode, you create elevation by casting a shadow. A dropdown menu sits on top of the page, so you add shadow-md, which renders a dark blur beneath the element. The background color of the dropdown remains identical to the page background.

In dark mode, shadows are invisible. You cannot cast a dark shadow on a slate-950 background. To communicate elevation in dark mode, you must change the lightness of the surface itself. The base background is slate-950, but the dropdown menu must be slate-900, and a modal sitting above that must be slate-800.

If you hardcode dark:bg-slate-950 on your card components, your UI flattens into a single, illegible plane.

Depth Requires Semantic Tokens

Stop naming your colors after what they look like. Name them after what they do.

When you use text-gray-900, you are permanently tying the component's structure to a specific visual output. You are forcing yourself to write dark:text-gray-100 every single time you use it.

Semantic tokens abstract the intent away from the hex code. You define a surface token for card backgrounds, an elevated token for dropdowns, and a muted token for secondary text.

Component ElementHardcoded ApproachSemantic ApproachDark Mode Behavior
App Backgroundbg-white dark:bg-zinc-950bg-backgroundMaps to #09090b
Card Backgroundbg-white dark:bg-zinc-900bg-surfaceMaps to #18181b (lighter to show elevation)
Primary Texttext-zinc-900 dark:text-whitetext-foregroundMaps to #fafafa
Secondary Texttext-zinc-500 dark:text-zinc-400text-mutedMaps to #a1a1aa
Bordersborder-zinc-200 dark:border-zinc-800border-borderMaps to #27272a

Using semantic tokens means your React components never know if they are in light or dark mode. They simply ask for bg-surface, and the underlying CSS variables handle the color delivery.

The Physics of Dark Mode Contrast

Pure white text (#ffffff) on a pure black background (#000000) is a design failure.

This combination creates a contrast ratio of 21:1. While WebAIM WCAG guidelines recommend a minimum 4.5:1 ratio for readability, maxing out the contrast causes a visual phenomenon called halation. Halation occurs when bright pixels bleed into adjacent dark pixels. For the roughly 30% of your users with astigmatism, pure white text on a pure black background appears to vibrate or blur, causing immediate eye strain.

Instead of maximizing contrast, you need to manage it. Your dark mode background should be an off-black, like #09090b (zinc-950). Your primary text should be an off-white, like #e4e4e7 (zinc-200). This drops the contrast ratio to a comfortable 11:1—high enough to be perfectly legible, low enough to prevent astigmatic halation.

OLED screens introduce another hardware constraint. Developers often request pure #000000 backgrounds to "save battery" on OLED displays, because pure black turns the pixel off entirely.

Never use pure black for scrolling surfaces. When an OLED pixel turns completely off, it takes approximately 1 to 2 milliseconds to turn back on when a lighter element scrolls over it. This delay causes "OLED smearing"—a purple or black ghosting trail that follows text and images as the user scrolls. Use #0a0a0a. The pixel stays active at its lowest voltage, eliminating the smearing effect while still providing 99% of the battery-saving benefits.

CSS Custom Properties: The Foundation

You implement semantic tokens using CSS variables.

Tailwind CSS requires a specific syntax to allow its opacity modifiers (like bg-primary/50) to work. You cannot use hex codes in your variables if you want opacity support. You must define your variables as raw, space-separated HSL (Hue, Saturation, Lightness) values.

Code
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 240 10% 3.9%;
    --surface: 0 0% 100%;
    --elevated: 0 0% 100%;
    --muted: 240 3.8% 46.1%;
    --border: 240 5.9% 90%;
    --primary: 240 5.9% 10%;
    --primary-foreground: 0 0% 98%;
  }
 
  .dark {
    --background: 240 10% 3.9%;
    --foreground: 0 0% 98%;
    
    /* Notice surface and elevated are lighter than background */
    --surface: 240 10% 6%;
    --elevated: 240 10% 12%;
    
    --muted: 240 5% 64.9%;
    --border: 240 3.7% 15.9%;
    --primary: 0 0% 98%;
    --primary-foreground: 240 5.9% 10%;
  }
}
HSL is the only color space that makes sense for programmatic design systems. If you need to make a dark mode border slightly lighter to improve visibility, you simply increase the third number (Lightness) from `15.9%` to `20%`. You cannot do that math in your head with a hex code or RGB value.
 
## Wiring Tailwind to Your Tokens
 
Once your CSS variables are defined, you map them into your `tailwind.config.ts`. 
 
You wrap the `var(--token)` in an `hsl()` function, and include `<alpha-value>` to preserve Tailwind's opacity modifier API.
 
```typescript
import type { Config } from "tailwindcss";
 
const config: Config = {
  content: ["./app/**/*.{js,ts,jsx,tsx,mdx}"],
  darkMode: "class",
  theme: {
    extend: {
      colors: {
        background: "hsl(var(--background) / <alpha-value>)",
        foreground: "hsl(var(--foreground) / <alpha-value>)",
        surface: "hsl(var(--surface) / <alpha-value>)",
        elevated: "hsl(var(--elevated) / <alpha-value>)",
        muted: "hsl(var(--muted) / <alpha-value>)",
        border: "hsl(var(--border) / <alpha-value>)",
        primary: {
          DEFAULT: "hsl(var(--primary) / <alpha-value>)",
          foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
        },
      },
    },
  },
  plugins: [],
};
 
export default config;

Building this 50-variable matrix manually takes hours of tweaking HSL values to ensure the contrast ratios pass WCAG standards across both light and dark modes. You don't have to build it from scratch. You can run OneMinuteBranding. You pay $49 one-time, wait 60 seconds, and it generates the exact CSS variables, tailwind.config.ts, and perfectly balanced color scales for your specific brand. It also outputs a CLAUDE.md file containing your design system rules, so your AI cursor prompts automatically use bg-surface instead of hardcoding bg-white.

When to Actually Use the dark: Variant

If you are using semantic tokens, you should almost never use the dark: variant for colors. className="bg-surface text-foreground" handles both themes automatically.

Reserve the dark: variant for structural changes that only apply to one theme.

The most common use case is borders vs shadows. In light mode, you use shadows for elevation. In dark mode, shadows are invisible, so you use subtle borders instead.

Code
<div className="bg-surface shadow-lg dark:shadow-none dark:border dark:border-border rounded-xl p-6">
  <h3 className="text-foreground font-semibold">Authentication</h3>
  <p className="text-muted text-sm mt-2">Enter your credentials to continue.</p>
</div>

This component casts a shadow in light mode. When switched to dark mode, the shadow is removed (dark:shadow-none), and a 1px border is applied to define the edges of the card against the dark background. The colors automatically shift via the CSS variables.

FAQ

How do I prevent the white flash on initial page load?

The Flash of Inaccurate Theme (FOIT) happens when your React app renders the default light mode HTML before the client-side JavaScript executes and applies the .dark class.

You fix this by injecting a blocking inline script into your <head> that checks localStorage or matchMedia before the browser paints the <body>. In Next.js App Router, you use next-themes. If you are writing raw HTML or using a standard Vite SPA, add this exact script inside your <head> tag:

Code
<script>
  if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
    document.documentElement.classList.add('dark')
  } else {
    document.documentElement.classList.remove('dark')
  }
</script>

Should I force users into dark mode based on system preference?

Read the system preference on their first visit, but always provide a manual toggle. Developers frequently override system settings for specific apps. If your app only respects the OS level prefers-color-scheme: dark media query and lacks a manual toggle, you are trapping users in a theme they may not want for your specific reading environment.

Do semantic tokens bloat my CSS?

No. Defining 20 CSS variables in your :root and .dark blocks adds less than 1KB to your stylesheet. Conversely, removing thousands of dark:bg-slate-900 dark:text-slate-100 classes from your HTML payload significantly reduces your total DOM size. Semantic tokens make your application faster to ship and lighter over the wire.


Open your codebase right now. Run a global search for dark:text- and dark:bg-. If you see more than 10 results, your dark mode is a liability, not a feature. Delete the hardcoded classes. Define your --surface and --foreground variables in your global CSS. Map them in your Tailwind config. Update your base layout components to use the semantic names. You will permanently eliminate theme-related bugs from your UI.

Y
Yann Lephay@YannBuilds

Vibe coder & Indie Hacker. Building tools to help devs ship faster. Creator of OneMinuteBranding.

Ready to create your brand?

Generate a complete brand system with Tailwind config in 60 seconds.

Generate your brand

Related articles

CSS Variables vs Tailwind Config: Where Should Your Brand Live?

Both work for storing brand tokens. But they serve different purposes. Here's when to use CSS custom properties and when to use tailwind.config.ts.

How to Set Up a Design System in 10 Minutes (Without a Designer)

You don't need Figma, a design team, or a 200-page style guide. Here's how to set up a functional design system with Tailwind and CSS variables in under 10 minutes.

Design Tokens in 2026: Why Every Developer Needs Them

Design tokens replace hardcoded colors, spacing, and fonts with a single source of truth. Here's how to generate them in seconds — with code examples.

Explore more

Branding by RoleBranding by IndustryUse CasesFeaturesIntegrationsGlossaryFree Tools
BlogAboutTermsPrivacy