light, dark or the system's, as one radio group, for any theme provider
npx shadcn add @ag/theme-toggleAlready installed? Preview an update
npx shadcn add @ag/theme-toggle --dry-runYour installed source stays unchanged until you update it. How updates work
the demo above, verbatim
"use client"
import { useTheme } from "next-themes"
import { useSyncExternalStore } from "react"
import { Sample } from "@/app/samples"
import { type Theme, ThemeToggle } from "@/registry/base-nova/ui/theme-toggle"
// #region hydration
const subscribe = () => () => {}
const clientSnapshot = () => true
const serverSnapshot = () => false
// #endregion
export default function Demo() {
// next-themes here, but the item does not know that: it takes a value and a setter,
// so any provider, or none, drives it the same way. `theme` is the CHOICE (system
// included); `resolvedTheme` is what that currently means.
// #region theme
const { theme, resolvedTheme, setTheme } = useTheme()
const hydrated = useSyncExternalStore(
subscribe,
clientSnapshot,
serverSnapshot,
)
// #endregion
return (
<div className="space-y-8">
<Sample
name="theme"
glance
title="compact icon picker"
with="hydration theme"
>
<ThemeToggle
value={hydrated ? (theme as Theme | undefined) : undefined}
onChange={(t) => setTheme(t)}
className="w-fit"
/>
<div className="space-y-2 font-mono text-xs text-muted-foreground">
<div>chosen · {hydrated ? theme : "…"}</div>
<div>resolves to · {hydrated ? resolvedTheme : "…"}</div>
</div>
</Sample>
<p className="max-w-prose text-[0.9375rem] leading-relaxed text-foreground/70">
Pick <span className="font-mono text-xs">system</span> and change the
appearance in macOS System Settings with this page open: it follows,
live. A toggle that stored a boolean would have stopped listening the
moment it was first pressed.
</p>
<p className="max-w-prose text-[0.9375rem] leading-relaxed text-foreground/70">
Everything on this site is wired to it. The tokens item carries both
halves, so an item that reads them has nothing to switch: only the
handful of components with a palette of their own (the terminal's
phosphor, Telegram's client colours) ever name a theme at all.
</p>
</div>
)
}the source, every file an install writes
"use client"
// A three-state theme control: light, dark, and FOLLOW THE SYSTEM. Most toggles
// collapse that to a boolean, which quietly throws away the only setting a reader
// actually chose, and stops tracking when they change it at the OS.
//
// It is deliberately provider-agnostic: it takes the value and a setter, so it works
// with next-themes, with a hand-rolled context, or with nothing at all. Depending on
// a provider would make the item unusable to anyone who already has one.
import { Monitor, Moon, Sun } from "lucide-react"
import {
PreviewPicker,
type PreviewPickerProps,
} from "@/registry/base-nova/ui/preview-picker"
export type Theme = "light" | "dark" | "system"
const THEMES: { value: Theme; label: string; Icon: typeof Sun }[] = [
{ value: "light", label: "light", Icon: Sun },
{ value: "system", label: "system", Icon: Monitor },
{ value: "dark", label: "dark", Icon: Moon },
]
export interface ThemeToggleProps
extends Omit<PreviewPickerProps<Theme>, "value" | "label" | "options"> {
/** The chosen theme, `system` included. NOT the resolved one: a control that shows
* `dark` when the reader picked `system` at night is reporting a fact they did not
* choose, and the next press then reads as a no-op.
*
* Undefined while a provider is still resolving, which is a real render on every
* load: it shows `system`, the provider's own default, rather than nothing, because
* a three-way control with no option marked reads as broken, and a reader cannot
* tell it apart from one whose selected state is invisible. */
value: Theme | undefined
onChange: (theme: Theme) => void
className?: string
}
export function ThemeToggle({
value,
onChange,
className,
...props
}: ThemeToggleProps) {
return (
<PreviewPicker
{...props}
label="Theme"
value={value ?? "system"}
onChange={onChange}
options={THEMES.map(({ value, label, Icon }) => ({
value,
label,
icon: <Icon aria-hidden />,
}))}
size="compact"
iconsOnly
className={className}
/>
)
}