← ui

theme-toggle

ui

light, dark or the system's, as one radio group, for any theme provider

npx shadcn add @ag/theme-toggle
Already installed? Preview an update
npx shadcn add @ag/theme-toggle --dry-run

Your installed source stays unchanged until you update it. How updates work

also called dark mode, theme switcher

shelf foundations

uses preview-picker

the demo above, verbatim

theme-toggle.demo.tsx
"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

theme-toggle.tsx
"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}
    />
  )
}