MGMeghraj Giri

Two Themes, One Tailwind Config: CSS Variables, next-themes and Contrast as the Constraint

How this site runs a light and a dark theme from one set of Tailwind classes using CSS variables and next-themes, and how WCAG contrast ratios decided the colours.

The first way most people add dark mode with Tailwind is the dark: variant: bg-white dark:bg-zinc-900, text-zinc-900 dark:text-zinc-100, on every element. It works, and on a small project it is fine.

On anything bigger it falls apart. Every component carries two colour schemes, they drift apart over time, and changing one shade means a search-and-replace across the codebase. This site has a light and a dark theme, and its components almost never mention either. Here is how that works, and how I picked the colours by measuring them instead of eyeballing them.

Name colours by role, not by value

The idea is simple. Components never ask for a specific colour. They ask for a role: background, foreground, card, muted text, border. Each theme then decides what colour fills each role.

In Tailwind, that means pointing the theme's colours at CSS variables:

// tailwind.config.ts
export default {
  darkMode: "class",
  theme: {
    extend: {
      colors: {
        background: "var(--background)",
        foreground: "var(--foreground)",
        card: {
          DEFAULT: "var(--card)",
          foreground: "var(--card-foreground)",
        },
        muted: {
          DEFAULT: "var(--muted)",
          foreground: "var(--muted-foreground)",
        },
        border: "var(--border)",
        input: "var(--input)",
        ring: "var(--ring)",
      },
    },
  },
};

Now bg-card, text-muted-foreground and border-border are ordinary Tailwind classes. A component reads like this whatever the theme:

<article className="rounded-md border border-border bg-card p-6">
  <h2 className="text-card-foreground">Project title</h2>
  <p className="text-muted-foreground">A short description.</p>
</article>

If you have used shadcn/ui, this is the same pattern it uses, and for the same reason.

Define each theme once, in CSS

The variables get their values in your global stylesheet: one block per theme.

/* globals.css */
:root,
.dark {
  color-scheme: dark;
  --background: #0d0d0c;
  --foreground: #e8eddf;
  --card: #242423;
  --muted-foreground: #a0b7ab;
  --border: #3a3c36;
  --input: #72756a;
}

.light {
  color-scheme: light;
  --background: #f2f5ec;
  --foreground: #242423;
  --card: #ffffff;
  --muted-foreground: #4a544c;
  --border: #dde2d6;
  --input: #8a8f88;
}

Two details are doing real work here.

The dark values are also on `:root`. This site is designed dark-first, so the page is dark before any theme class has been applied, and even with JavaScript switched off.

`color-scheme` is set per theme. It tells the browser to match its own UI to the theme: scrollbars, form controls and the default canvas colour. Without it you can get a bright white scrollbar on a dark page.

Changing a colour is now a one-line edit in one file, and every component that uses that role follows.

Switching themes with next-themes

On the React side, next-themes handles the theme toggle. It adds the theme class to the <html> element, remembers the visitor's choice in local storage, and injects a small script that applies the stored theme before the page renders.

// app/layout.tsx
import { ThemeProvider } from "next-themes";

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="dark"
          enableSystem={false}
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

`suppressHydrationWarning` on `<html>` is needed because next-themes changes that element's class before React hydrates, so the server's HTML and the browser's DOM will not match there. The prop applies only to that element's own attributes, one level deep, so a genuine mismatch inside your app is still reported.

`enableSystem={false}` is a product decision, not a technical one. With it on, visitors can follow their operating system's light or dark setting. This site is designed dark-first, so I turned it off and kept the toggle for anyone who prefers light.

`disableTransitionOnChange` briefly disables CSS transitions during the switch. Without it, every element with a transition-colors class animates at its own speed and the page shimmers for a moment.

Contrast as the constraint

Here is the part that made the biggest difference. Picking colours for two themes by eye goes wrong in a predictable way: they look fine on your monitor, then wash out on a cheap laptop in daylight.

So every text and border colour on this site was checked against the WCAG contrast thresholds:

  • 4.5:1 — minimum for normal body text (WCAG AA)
  • 7:1 — the stricter AAA level for body text
  • 3:1 — minimum for UI component boundaries, such as the border showing where a text field starts (WCAG 1.4.11)

The measured ratios turned into rules about what each role may be used for:

  • muted-foreground on a card is 7.3:1 in dark and 7.9:1 in light. That passes AAA, so it is allowed for body copy.
  • faint is 4.6:1 on a dark card. That passes AA but only just, so it is reserved for small mono labels and dates, never paragraphs.
  • input borders are 3.3:1 against the card in both themes. That clears the 3:1 bar for form controls. Decorative borders have no such requirement and can be much quieter.

Measuring also exposed a bug I could not see. The dark background was originally #1d1d1c, with cards in #242423. Those values are different in the stylesheet, but measured against each other the ratio was 1.09:1 — effectively the same colour. Cards did not read as surfaces at all. Darkening the background to #0d0d0c took that step to 1.25:1, enough for a card to sit visibly on the page, and every text colour's contrast improved as a side effect.

The light theme had the opposite problem. Darkening its background to make white cards stand out was limited by faint text: one step darker than #f2f5ec and that text would fall under 4.5:1 against the page. So the background is exactly as dark as the contrast budget allows, and no darker.

What you get

Components that never mention a theme. A palette that changes in one file. And colour decisions you can explain with a number rather than "it looked right to me", which is a better conversation to have with a designer or a client.

If you want to do the same, open Chrome DevTools, click any colour swatch in the Styles pane, and it shows the contrast ratio against the background with AA and AAA marks. Put the number in a comment next to the variable, and the next person to change it will know what they are risking.