Theme Toggle

Light/dark toggle button with a head script that prevents theme flash.

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.json

Usage

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:

  1. ThemeScript renders an inline script that reads storageKey from localStorage, falls back to defaultTheme, and adds the resolved class to document.documentElement. Without it the theme is applied after hydration, which flashes.
  2. ThemeToggle works on its own as an island. It renders with defaultTheme on the server and on the first client render, then reads the stored preference after mount, so hydration never mismatches. It flips between the resolved light and dark values, sets aria-pressed and data-state, and accepts all button props.
  3. defaultTheme accepts "light", "dark", or "system" (default). system follows prefers-color-scheme and updates when the OS setting changes. storageKey defaults to "theme".
  4. ThemeProvider and useTheme share 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.
  5. The Theme and ThemeOptions types are exported for typing your own code.