Mischief

43 / Controls

Theme Toggle

A light and dark switch that survives a reload, follows the system when asked, and stays in step across tabs.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/theme-toggle
import { ThemeToggle } from "mischief-ui/theme-toggle"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/theme-toggle/theme-toggle.tsx
"use client" import * as React from "react"import { Monitor, Moon, Sun } from "lucide-react" import { cn } from "@/lib/utils" export type ThemeMode = "light" | "dark" | "system" export type ThemeToggleProps = Omit<  React.ButtonHTMLAttributes<HTMLButtonElement>,  "onChange" | "children"> & {  /** Modes to cycle through. Defaults to light and dark. */

Usage

export function Header() {
  return <ThemeToggle modes={["light", "dark", "system"]} />
}

Stopping the flash

The toggle cannot prevent a flash of the wrong theme on the first paint, and no component can. The server has no way to know what the reader chose, so the page ships in one theme and corrects itself once React takes over -- which is late enough to see.

Fixing it means setting the class before the page paints, with a small blocking script in the document head. This runs once, before anything is rendered, and matches what applyTheme does afterwards.

// app/layout.tsx
const setTheme = `(() => {
  try {
    const stored = localStorage.getItem("theme")
    const dark = stored
      ? stored === "dark"
      : matchMedia("(prefers-color-scheme: dark)").matches
    document.documentElement.classList.toggle("dark", dark)
    document.documentElement.style.colorScheme = dark ? "dark" : "light"
  } catch {}
})()`

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script dangerouslySetInnerHTML={{ __html: setTheme }} />
      </head>
      <body>{children}</body>
    </html>
  )
}
Keep the storage key and class in step with the props you pass the toggle.

suppressHydrationWarning belongs on the html element because the script has changed it before React compares. It applies to that element only, not to your tree.

Modes

The default is a plain light and dark switch. Include system and the toggle gains a third state that clears the stored choice and hands the decision back to the operating system, tracking later changes to it while the page is open.

With nothing stored, the toggle starts on system when system is one of its modes. The first click therefore moves to whatever follows system in your list, not to the first mode in it.

API

modesThemeMode[]Modes to cycle through. Defaults to ["light", "dark"].
storageKeystringWhere the choice is remembered. Defaults to "theme".
darkClassstringClass placed on the root element. Defaults to "dark".
onThemeChange(mode: ThemeMode) => voidCalled after the mode is applied.
iconsPartial<Record<ThemeMode, ReactNode>>Replaces the icon for any mode.
labelsPartial<Record<ThemeMode, string>>Renames a mode in the accessible label.

Accessibility

The button is named for what it will do next rather than the current state, so it never reads as a checkbox that lies. Reading the mode goes through useSyncExternalStore, so the server renders the first mode and the client corrects it on hydration without a flash of the wrong icon. Storage events keep other tabs in step, and the system preference is watched while system is one of the modes. Refusing storage in private browsing is caught, and the choice still holds for the page.