A sun/moon button that switches the light or dark class on the document root and remembers the choice in localStorage. A small head script applies the stored theme before first paint so pages never flash.
Install
pnpm dlx shadcn@latest add https://sitex.full.dev/r/theme-toggle.jsonUsage
Render ThemeScript in the document head and ThemeToggle as an island wherever the button belongs. Give both the same options.
import { ThemeScript, ThemeToggle } from "@/components/ui/theme-toggle"
const theme = { defaultTheme: "system", storageKey: "theme" } as const
export default function Layout({ children }) {
return (
<html>
<head>
<ThemeScript {...theme} />
</head>
<body>
<header>
<ThemeToggle {...theme} client:idle />
</header>
{children}
</body>
</html>
)
}Key parts:
ThemeScriptrenders an inline script that readsstorageKeyfromlocalStorage, falls back todefaultTheme, and adds the resolved class todocument.documentElement. Without it the theme is applied after hydration, which flashes.ThemeToggleworks on its own as an island. It renders withdefaultThemeon the server and on the first client render, then reads the stored preference after mount, so hydration never mismatches. It flips between the resolvedlightanddarkvalues, setsaria-pressedanddata-state, and accepts allbuttonprops.defaultThemeaccepts"light","dark", or"system"(default).systemfollowsprefers-color-schemeand updates when the OS setting changes.storageKeydefaults to"theme".ThemeProvideranduseThemeshare one theme state between several controls inside the same island. Context does not cross island boundaries, so the provider and its controls must hydrate together.- The
ThemeandThemeOptionstypes are exported for typing your own code.