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:
@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.darkon<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.disableTransitionOnChangesuppresses 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:
import { ModeToggle } from '@kreogen/design-system/components/mode-toggle';
const MyPage = () => <ModeToggle />;Reading the theme in code
'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.