Theming
Theming is purely a token-layer behavior: put the dark class on <html> and the .dark block in @kothry/ui/styles.css flips every semantic and component token to its dark mapping — zero component changes. The design currently defines light only; dark values are derived by mirroring the grey scales, to be replaced once official dark values ship.
How it works
Both cards below render identical component code — the right one just sits inside a dark-classed wrapper, so every token resolves to its dark value.
light
Tokens flip, components stay untouched.
dark
Tokens flip, components stay untouched.
<div className="dark">
{/* every component inside resolves dark tokens */}
</div>Setup with next-themes
Class strategy + system preference + no-flash. suppressHydrationWarning is required because next-themes mutates the html class before hydration.
pnpm add next-themes — that and the two snippets above are the whole setup. / 装上 next-themes,加上面两段代码,接入就完成了。
// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
);
}
// app/layout.tsx
<html lang="en" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>Building a switcher
useTheme + DropdownMenu — this is the exact pattern behind the theme control in this site’s header. Gate the current value on mounted to avoid hydration mismatch.
Try it live in the header of this page. / 本页头部右上角即为线上实例。
'use client';
import { useTheme } from 'next-themes';
import { Monitor, Moon, Sun } from 'lucide-react';
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@kothry/ui';
function ThemeSwitcher() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const current = mounted ? (theme ?? 'system') : 'system';
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button iconOnly variant="ghost" aria-label="Theme">
{current === 'light' ? <Sun /> : current === 'dark' ? <Moon /> : <Monitor />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setTheme('system')}><Monitor /> System</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setTheme('light')}><Sun /> Light</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setTheme('dark')}><Moon /> Dark</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}Scoped dark regions
Because tokens cascade, the dark class works on any subtree — useful for permanently dark surfaces like code panels or marketing heroes, independent of the page theme.
<div className="dark">
<div className="rounded-md bg-bg-primary p-4 text-text-primary">
Always dark, regardless of the page theme.
</div>
</div>