Theme toggle
Switches the html dark class and remembers the choice, with no theme library.
npx shadcn@latest add https://ui.goodtabs.co/r/theme-toggle.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/theme-toggle
The ghost icon button. Click it and this page switches theme.
Outline
The outline variant at the default and icon-sm sizes.
In a header row
Usage
import { ThemeToggle } from "@/components/ui/theme-toggle"
<ThemeToggle />
<ThemeToggle variant="outline" size="icon-sm" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | Button variant | "ghost" | Any shadcn button variant. Ghost for headers, outline on its own. |
| size | Button size | "icon" | Any icon size: icon is 32px, icon-sm 28px, icon-xs 24px. |
Source
theme-toggle/theme-toggle.tsx
"use client";
import * as React from "react";
import { MoonIcon, SunIcon, SunMoonIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type Theme = "light" | "dark";
const STORAGE_KEY = "theme";
function subscribe(onChange: () => void) {
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}
function readTheme(): Theme {
return document.documentElement.classList.contains("dark") ? "dark" : "light";
}
/** The theme is null until mounted, so server and client markup match. */
function useHtmlTheme(): Theme | null {
return React.useSyncExternalStore(subscribe, readTheme, () => null);
}
function applyTheme(next: Theme) {
const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(next);
root.style.colorScheme = next;
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Storage can be blocked (private mode, quota). The class still switches for this visit.
}
}
type ThemeToggleProps = Omit<React.ComponentProps<typeof Button>, "onClick" | "children" | "aria-label">;
/**
* Switches the html dark class and remembers the choice in localStorage under "theme".
* No theme library: it reads the class after mount and watches it for outside changes.
*/
export function ThemeToggle({ variant = "ghost", size = "icon", className, ...props }: ThemeToggleProps) {
const theme = useHtmlTheme();
const Icon = theme === null ? SunMoonIcon : theme === "dark" ? SunIcon : MoonIcon;
const label = theme === null ? "Switch theme" : theme === "dark" ? "Switch to light theme" : "Switch to dark theme";
return (
<Button
type="button"
variant={variant}
size={size}
aria-label={label}
data-theme={theme ?? undefined}
onClick={() => applyTheme((theme ?? readTheme()) === "dark" ? "light" : "dark")}
className={cn("text-muted-foreground transition-colors", className)}
{...props}
>
<Icon aria-hidden="true" />
</Button>
);
}
For AI agents
Hand your agent this page, the markdown at /components/theme-toggle/prompt.md, or the Copy AI prompt button above.
When to use
- In the header or footer of a site that has a light and a dark theme
- In a settings row or command menu as the one place to switch
- When you do not want a theme library for a single toggle
When not to use
- For a third system option; this is a two-state switch
- When the theme is set per user on the server; wire the choice to your own store instead
- If the site has no dark theme yet; add the dark tokens first
Agent notes
- It toggles the dark class on html and writes light or dark to localStorage under the key theme; add a small inline script in the head that reads that key before paint to avoid a flash
- It renders a neutral icon and the label Switch theme until mounted, then reads the html class, so there is no hydration mismatch
- On goodtabs.co next-themes reads the same key and class, so both stay in sync; a next-themes toggle on the same page keeps working
- It watches the html class, so a theme set from anywhere else on the page updates the icon and label