DEV Community

Cover image for Why Dark Mode Should Not Be a Second CSS File
Hasan Sarwer
Hasan Sarwer

Posted on

Why Dark Mode Should Not Be a Second CSS File

Dark mode often begins with a simple idea:

styles.css
dark.css
Enter fullscreen mode Exit fullscreen mode

The light theme lives in one file.

Dark-mode overrides live in another.

For a small interface, this can appear perfectly reasonable.

But as the application grows, the two files begin to behave like two separate implementations of the same UI.

A component changes in styles.css.

The corresponding rule in dark.css is forgotten.

A new hover state is added.

A focus ring is updated.

A disabled style changes.

Now every improvement to the default interface requires another matching override in the dark-mode file.

Dark mode becomes expensive because the theme is implemented at the component level.

That is the real problem.

Dark mode should not be a second version of your component CSS.

It should be a different set of token values.

The duplicated CSS approach

Imagine a card written like this:

.card {
  background: #ffffff;
  color: #111827;
  border: 1px solid #e5e7eb;
}
Enter fullscreen mode Exit fullscreen mode

To support dark mode, a team may create another rule:

/* dark.css */

.card {
  background: #0f172a;
  color: #f8fafc;
  border-color: #334155;
}
Enter fullscreen mode Exit fullscreen mode

Then a button needs the same treatment:

/* styles.css */

.button-primary {
  background: #2563eb;
  color: #ffffff;
}

.button-primary:hover {
  background: #1d4ed8;
}
Enter fullscreen mode Exit fullscreen mode

And another dark-mode override:

/* dark.css */

.button-primary {
  background: #60a5fa;
  color: #0f172a;
}

.button-primary:hover {
  background: #93c5fd;
}
Enter fullscreen mode Exit fullscreen mode

This works.

But the theme logic is now spread across component selectors.

Every component needs to know how it looks in light mode and dark mode.

That creates duplication.

The files eventually drift

Suppose the default button receives a new focus state:

.button-primary:focus-visible {
  outline: 3px solid #93c5fd;
  outline-offset: 2px;
}
Enter fullscreen mode Exit fullscreen mode

Someone must remember to check whether the dark theme also needs a different focus color.

Then the disabled state changes:

.button-primary:disabled {
  background: #e5e7eb;
  color: #94a3b8;
}
Enter fullscreen mode Exit fullscreen mode

Again, the dark-mode implementation needs another override.

The same issue appears with:

  • hover states
  • pressed states
  • focus rings
  • disabled states
  • selected states
  • borders
  • shadows
  • muted text
  • elevated surfaces
  • alerts
  • inputs
  • placeholders

The problem is not that developers cannot write these overrides.

The problem is that the system requires them to remember the same relationship in multiple places.

That is how theme drift begins.

Keep component CSS theme-independent

A better approach is to write components using semantic tokens:

.card {
  background: var(--color-surface);
  color: var(--color-foreground);
  border: 1px solid var(--color-border);
}

.button-primary {
  background: var(--color-primary);
  color: var(--color-on-primary);
}

.button-primary:hover {
  background: var(--state-primary-hover);
}

.button-primary:active {
  background: var(--state-primary-pressed);
}

.button-primary:focus-visible {
  outline: 3px solid var(--state-primary-focus);
  outline-offset: 2px;
}

.button-primary:disabled {
  background: var(--state-primary-disabled-background);
  color: var(--state-primary-disabled-foreground);
}
Enter fullscreen mode Exit fullscreen mode

There is no dark-mode selector here.

The component does not need one.

It only needs to know the semantic role of each value.

The theme decides what those values should be.

Define the light theme as token values

The default theme can be declared at the root:

:root {
  color-scheme: light;

  --color-background: #ffffff;
  --color-foreground: #111827;

  --color-surface: #f8fafc;
  --color-surface-elevated: #ffffff;
  --color-border: #e5e7eb;
  --color-muted: #64748b;

  --color-primary: #2563eb;
  --color-on-primary: #ffffff;

  --color-warning: #facc15;
  --color-on-warning: #111827;

  --color-danger: #dc2626;
  --color-on-danger: #ffffff;

  --state-primary-hover: #1d4ed8;
  --state-primary-pressed: #1e40af;
  --state-primary-focus: #93c5fd;

  --state-primary-disabled-background: #e5e7eb;
  --state-primary-disabled-foreground: #94a3b8;
}
Enter fullscreen mode Exit fullscreen mode

These variables describe UI roles.

They do not describe a particular component.

--color-surface can be used by cards, dialogs, menus, inputs, and other surfaces.

--color-on-primary defines the readable foreground that belongs on the primary background.

The component receives a contract instead of a collection of hardcoded values.

Dark mode should override the tokens

Dark mode can now change the same semantic roles:

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

  --color-background: #020617;
  --color-foreground: #f8fafc;

  --color-surface: #0f172a;
  --color-surface-elevated: #1e293b;
  --color-border: #334155;
  --color-muted: #94a3b8;

  --color-primary: #60a5fa;
  --color-on-primary: #0f172a;

  --color-warning: #facc15;
  --color-on-warning: #111827;

  --color-danger: #f87171;
  --color-on-danger: #450a0a;

  --state-primary-hover: #93c5fd;
  --state-primary-pressed: #bfdbfe;
  --state-primary-focus: #60a5fa;

  --state-primary-disabled-background: #1e293b;
  --state-primary-disabled-foreground: #64748b;
}
Enter fullscreen mode Exit fullscreen mode

The token names remain unchanged.

Only their values change.

The card still uses:

background: var(--color-surface);
color: var(--color-foreground);
Enter fullscreen mode Exit fullscreen mode

The button still uses:

background: var(--color-primary);
color: var(--color-on-primary);
Enter fullscreen mode Exit fullscreen mode

The component contract is stable across both themes.

One component, multiple themes

This is the important architectural difference.

With duplicated theme CSS, the structure looks like this:

Light card CSS
Dark card CSS

Light button CSS
Dark button CSS

Light input CSS
Dark input CSS
Enter fullscreen mode Exit fullscreen mode

With semantic tokens, the structure becomes:

One card component
One button component
One input component

Light token values
Dark token values
Enter fullscreen mode Exit fullscreen mode

The first approach multiplies component implementations.

The second approach keeps the implementation stable and changes the data supplied to it.

That is much easier to maintain.

Use the same semantic vocabulary in every mode

Light and dark themes should expose the same token names.

For example:

background
foreground
surface
surfaceElevated
border
muted

primary
onPrimary

warning
onWarning

danger
onDanger
Enter fullscreen mode Exit fullscreen mode

A theme should not expose darkCardBackground or lightCardBackground to the component.

That would make the component aware of the active mode.

Avoid this:

.card {
  background: var(--light-card-background);
}

[data-theme="dark"] .card {
  background: var(--dark-card-background);
}
Enter fullscreen mode Exit fullscreen mode

Prefer this:

.card {
  background: var(--color-surface);
}
Enter fullscreen mode Exit fullscreen mode

The component asks for a surface.

The active theme provides the correct surface value.

Keep foreground and background pairs together

Dark mode often reveals contrast assumptions that were already present in the light theme.

For example:

.button-primary {
  background: var(--color-primary);
  color: white;
}
Enter fullscreen mode Exit fullscreen mode

This assumes every primary background will be dark enough for white text.

But a dark theme may use a lighter primary color:

:root[data-theme="dark"] {
  --color-primary: #60a5fa;
}
Enter fullscreen mode Exit fullscreen mode

The background changed.

The hardcoded foreground did not.

A safer structure defines the pair:

:root[data-theme="dark"] {
  --color-primary: #60a5fa;
  --color-on-primary: #0f172a;
}
Enter fullscreen mode Exit fullscreen mode

Then:

.button-primary {
  background: var(--color-primary);
  color: var(--color-on-primary);
}
Enter fullscreen mode Exit fullscreen mode

Dark mode should change readable relationships, not just isolated background colors.

Interaction states belong in the theme too

It is not enough to theme the default component state.

Interactive states should also use tokens:

.button-primary:hover {
  background: var(--state-primary-hover);
}

.button-primary:active {
  background: var(--state-primary-pressed);
}

.button-primary:focus-visible {
  outline-color: var(--state-primary-focus);
}

.button-primary:disabled {
  background: var(--state-primary-disabled-background);
  color: var(--state-primary-disabled-foreground);
}
Enter fullscreen mode Exit fullscreen mode

The light theme and dark theme can provide different values for these states.

But the component should continue using the same token names.

This prevents dark-mode behavior from being scattered across component-specific overrides.

Not every token needs a dark-mode version

Colors and some interaction states often change between themes.

Spacing usually does not.

Neither do most radius or font-size tokens.

For example:

:root {
  --space-xs: 0.25rem;
  --space-sm: 0.5rem;
  --space-md: 1rem;
  --space-lg: 1.5rem;
  --space-xl: 2rem;

  --radius-sm: 0.25rem;
  --radius-md: 0.5rem;
  --radius-lg: 0.75rem;
  --radius-full: 9999px;

  --font-size-sm: 0.875rem;
  --font-size-base: 1rem;
  --font-size-lg: 1.125rem;
  --font-size-xl: 1.25rem;
}
Enter fullscreen mode Exit fullscreen mode

These values can remain global.

A clean token structure separates mode-dependent tokens from shared tokens.

const tokens = {
  modes: {
    light: {
      colors: {},
      states: {}
    },
    dark: {
      colors: {},
      states: {}
    }
  },

  spacing: {},
  radius: {},
  fontSizes: {}
};
Enter fullscreen mode Exit fullscreen mode

Dark mode should not duplicate values that do not actually change.

Use a data attribute for explicit themes

A data attribute is a practical way to apply the active theme:

<html data-theme="dark">
Enter fullscreen mode Exit fullscreen mode

Then CSS can scope the token values:

:root {
  /* light token values */
}

:root[data-theme="dark"] {
  /* dark token values */
}
Enter fullscreen mode Exit fullscreen mode

Changing the theme only requires changing the attribute:

document.documentElement.dataset.theme = "dark";
Enter fullscreen mode Exit fullscreen mode

To return to light mode:

document.documentElement.dataset.theme = "light";
Enter fullscreen mode Exit fullscreen mode

Or remove the explicit theme to follow the operating system:

delete document.documentElement.dataset.theme;
Enter fullscreen mode Exit fullscreen mode

Support the system preference

A default system-driven dark theme can also be supported:

:root {
  color-scheme: light;

  --color-background: #ffffff;
  --color-foreground: #111827;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;

    --color-background: #020617;
    --color-foreground: #f8fafc;
  }
}
Enter fullscreen mode Exit fullscreen mode

This means:

  • the system preference is used by default
  • data-theme="light" forces light mode
  • data-theme="dark" can explicitly force dark mode

The exact theme-selection strategy may vary, but the token contract remains the same.

Apply the saved theme before the first paint

A well-structured token system can still flash the wrong theme if the saved preference is applied too late.

If JavaScript reads localStorage only after the application loads, the browser may paint the default theme first.

A small script in the document head can apply the saved theme earlier:

<script>
  (function () {
    try {
      var theme = localStorage.getItem("theme");

      if (theme === "light" || theme === "dark") {
        document.documentElement.dataset.theme = theme;
      }
    } catch (_) {}
  })();
</script>
Enter fullscreen mode Exit fullscreen mode

Place this before the main stylesheet when using a client-side saved preference.

The semantic-token architecture controls how themes are represented.

The early script controls when the correct theme is selected.

Both parts matter.

Component CSS should become boring

Well-structured component CSS is often simple:

.page {
  background: var(--color-background);
  color: var(--color-foreground);
}

.card {
  background: var(--color-surface);
  color: var(--color-foreground);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  padding: var(--space-lg);
}

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

.button-primary {
  background: var(--color-primary);
  color: var(--color-on-primary);
  border-radius: var(--radius-md);
  padding: var(--space-sm) var(--space-md);
}

.button-primary:hover {
  background: var(--state-primary-hover);
}
Enter fullscreen mode Exit fullscreen mode

There are no dark-mode selectors inside these components.

That is a feature.

The component is responsible for structure and behavior.

The theme is responsible for values.

When separate CSS files may still make sense

Separate stylesheets are not always wrong.

A separately loaded theme file can make sense when:

  • themes are delivered independently
  • a white-label product has large brand-specific assets
  • unused theme payloads must not be shipped
  • themes include more than token-value changes
  • the application supports externally installed themes

But even then, the theme file should ideally contain token declarations rather than duplicate component selectors.

For example:

/* theme-dark.css */

:root {
  --color-background: #020617;
  --color-foreground: #f8fafc;
  --color-surface: #0f172a;
  --color-primary: #60a5fa;
  --color-on-primary: #0f172a;
}
Enter fullscreen mode Exit fullscreen mode

This is still one component implementation with a separately delivered token set.

The problem is not the physical number of files.

The problem is duplicating component CSS for each mode.

A practical structure

A small project could use:

styles/
├── tokens.css
├── components.css
└── app.css
Enter fullscreen mode Exit fullscreen mode

tokens.css:

:root {
  /* shared tokens */
  /* light theme values */
}

:root[data-theme="dark"] {
  /* dark theme values */
}
Enter fullscreen mode Exit fullscreen mode

components.css:

.card {
  background: var(--color-surface);
  color: var(--color-foreground);
}

.button-primary {
  background: var(--color-primary);
  color: var(--color-on-primary);
}
Enter fullscreen mode Exit fullscreen mode

app.css:

@import "./tokens.css";
@import "./components.css";
Enter fullscreen mode Exit fullscreen mode

For a larger system, token values may be generated from JavaScript, JSON, DTCG files, or another shared source.

But the principle stays the same:

components consume semantic roles; themes provide values.

Final thought

Dark mode should not require developers to rebuild every component.

It should not mean:

light card + dark card
light button + dark button
light input + dark input
Enter fullscreen mode Exit fullscreen mode

It should mean:

one card
one button
one input

light token values
dark token values
Enter fullscreen mode Exit fullscreen mode

The rule is simple:

Dark mode should be a different set of token values—not a second implementation of the UI.

When component CSS stays stable, light and dark themes remain easier to maintain, easier to test, and less likely to drift apart.

Top comments (0)