Theme toggle
Free · MITLight/dark toggle that reveals the new theme as a circle from the click point with the View Transitions API while the sun morphs into a moon.
Installation
$ npx shadcn@latest add https://goodui.dev/r/theme-toggle.jsonThe file lands in components/goodui/theme-toggle.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/theme-toggle works too.
Usage
import { ThemeToggle } from "@/components/goodui/theme-toggle"
export function AppHeader() {
return (
<header className="flex h-14 items-center justify-between border-b px-4">
<span className="font-semibold">Brightside Cleaning</span>
<ThemeToggle storageKey="theme" />
</header>
)
}When to use
- The light/dark switch in an app header, settings menu or marketing navbar.
- Sites that toggle the `dark` class on <html> (the shadcn default) and want a polished switch without extra libraries.
- A settings row where a labeled switch is clearer: variant="pill".
- You need a third "System" option: use segmented-control with Light / Dark / System and set the class yourself.
- Your app is already wired to next-themes: call its setTheme from a button (or segmented-control); this component flips the <html> class directly and does not talk to next-themes.
- Toggling an ordinary setting: use switch.
| If you need | Use |
|---|---|
| Compact icon in a header | variant="icon" (default) |
| Two-icon switch in a settings panel | variant="pill" (role="switch") |
| No circular reveal | transition="none" |
| Remember the choice | storageKey="theme" plus a small script that applies it before paint |
Examples
Apply the saved theme before first paint (Next.js app/layout.tsx)
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: "try{if(localStorage.getItem('theme')==='dark'){document.documentElement.classList.add('dark')}}catch(e){}",
}}
/>
</head>
<body>{children}</body>
</html>Pill switch with translated labels
<ThemeToggle
variant="pill"
labels={{ darkTheme: "Modo oscuro", toLight: "Cambiar a tema claro", toDark: "Cambiar a tema oscuro" }}
onThemeChange={(theme) => saveUserPreference({ theme })}
/>API reference
| Prop | Type | Default |
|---|---|---|
| onThemeChange Called after the `dark` class on <html> changes. | (theme: Theme) => void | — |
| duration Milliseconds for the circular reveal. | number | 550 |
| transition "circle" uses the View Transitions API when available; "none" switches instantly. | "circle" | "none" | "circle" |
| storageKey Save the choice in localStorage under this key and apply the saved choice on mount (off by default). Add a head script to avoid a flash before hydration. | string | — |
| variant "icon" is a round icon button; "pill" a two-icon switch. | "icon" | "pill" | "icon" |
| labels Text for built-in controls and screen readers; pass translations here. | Partial<ThemeToggleLabels> | — |
Also accepts every <button> attribute.
Accessibility
| Enter / Space | Toggles the theme (native <button>); keyboard toggles start the reveal from the button center. |
| Tab | Moves focus; a focus ring shows on keyboard focus only. |
- variant="icon": a <button> whose aria-label says what will happen ("Switch to dark theme" / "Switch to light theme").
- variant="pill": role="switch" with aria-checked for dark and aria-label "Dark theme".
- All three strings come from labels, so they can be translated.
- It also sets color-scheme on <html>, so native scrollbars and form controls follow the theme.
Motion
- With the View Transitions API, the new theme spreads as a circle from the click point (or the button center for keyboard) over `duration` ms (550) on cubic-bezier(0.4, 0, 0.2, 1).
- The icon morphs on a spring (stiffness 260, damping 22): the sun's core grows into a moon as a mask circle slides in and the rays spin away; the icon variant also turns once (stiffness 120, damping 16). The pill thumb slides on a spring (500, 32).
- The theme found on mount is shown without animation; only real changes animate.
- transition="none" or no View Transitions support: the theme switches instantly while the icon still morphs.
- Reduced motion: the theme switches instantly, the icon and pill thumb change without animation, and there is no tap scale.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- It toggles the `dark` class and color-scheme on document.documentElement; it does not render children and has no value prop. It mirrors outside class changes through a MutationObserver.
- storageKey saves the choice and applies the saved choice when the toggle mounts. That happens after hydration, so also add the small <head> script that applies it before paint, or the page can flash.
- If you use next-themes, do not combine it with this component; both would write the <html> class.
- The button is invisible (opacity 0) until mounted, to avoid showing the wrong icon during hydration.
- Translate through labels: toLight, toDark, darkTheme.
FAQ
Why does my page flash light on reload?
With storageKey the toggle saves the choice and re-applies it on mount, but it cannot run before the first paint. Add a tiny inline script in <head> that reads the key and adds the dark class.
Which browsers show the circular reveal?
Browsers that support document.startViewTransition. Others switch instantly, with the icon still animating.
Related
iOS, Material 3, square, neo-brutalist, icon thumb and ON/OFF label variants with none, smooth, spring and bounce motion, six tones, three sizes, track shadows, sun/moon or check/x glyphs, label and description on either side, and a loading state.
iOS-style segmented control whose thumb springs to the chosen segment and squeezes while pressed, with sizes, icons, full-width mode, dividers that hide next to the thumb, and radio-group arrow keys.
Switch with a springy thumb that squashes while pressed, icons that spin inside the thumb, three sizes, label and description.
One button with ten variants, six tones, seven shadows, ten animations, three shapes and five sizes that all combine, plus loading and icon slots.