Design System

Dark Mode

How the dark variant is defined and switched.

Dark mode is a dark class on the <html> element, put there by next-themes and read by a Tailwind custom variant.

The variant

Tailwind v4 has no darkMode config option, because there is no config file. The variant is declared in CSS instead:

packages/design-system/styles/globals.css
@custom-variant dark (&:is(.dark *));

That is what makes dark:bg-card compile. It reads as "when an ancestor has .dark", which is a descendant selector — the element carrying the class is not itself matched, only what is inside it. Since the class goes on <html>, that covers the entire document.

Most components never use the variant. The token layer already defines each semantic value twice — once in :root, once in .dark — so bg-background changes on its own. Reach for dark: only when a component needs a genuinely different treatment rather than a different colour: a border that exists only on dark, an image that needs inverting.

The switch

ThemeProvider wraps next-themes and is mounted by DesignSystemProvider:

<NextThemeProvider
  attribute="class"
  defaultTheme="system"
  enableSystem
  disableTransitionOnChange
>
  • attribute="class" is what puts .dark on <html>, matching the variant above. Change one and you must change the other.
  • defaultTheme="system" means each app follows the operating system until the user chooses otherwise.
  • disableTransitionOnChange suppresses CSS transitions during the swap. Without it every transitioned property animates at once and the whole page visibly wipes.

The theme is resolved on the client, so the server cannot know it. Each app's <html> carries suppressHydrationWarning because next-themes writes the class before React hydrates — without it, React reports a mismatch on every page load in development.

Letting the user choose

ModeToggle is already in the app sidebar and the web navbar, and can go anywhere:

page.tsx
import { ModeToggle } from '@kreogen/design-system/components/mode-toggle';

const MyPage = () => <ModeToggle />;

Reading the theme in code

page.tsx
'use client';

import { useTheme } from 'next-themes';

const MyPage = () => {
  const { resolvedTheme } = useTheme();

  return resolvedTheme === 'dark' ? 'Dark' : 'Light';
};

Use resolvedTheme, not theme. theme can be the literal string "system", which is not something you can branch a colour on.

Both are undefined on the first client render, before next-themes has read storage. Rendering directly from either causes a flash of the wrong branch, so gate on mount for anything visible.

Third-party components

Sonner is configured to follow the same provider, and the authentication screens inherit it automatically because they are built from these components. Anything you add that has its own theme prop should be pointed at resolvedTheme rather than given a fixed value.