← kothry

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

ActiveHigh

Tokens flip, components stay untouched.

dark

ActiveHigh

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.

Always dark, regardless 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>