Theme Toggle
A single-button theme control that cycles the stored preference and paints the kit's three theme icons.
Installation
npx shadcn@latest add https://ui.mattdowney.com/r/theme-toggle.jsonUsage
import { ThemeToggle } from "@/components/ui/theme-toggle"Examples
Default
Controlled, so it carries no theme library and ships in the core kit. This one is wired to next-themes and switches this site for real. theme is the stored preference and drives the glyph; resolvedTheme is optional and only enriches the accessible name.
With system
Pass themes to add a third stop, so someone who has picked a side can hand the choice back to the OS. System gets a glyph of its own, the same keyboard the forms below use. The button still names none of the three out loud, so where the choice matters, use the labelled form below.
On a settings surface
A cycle button cannot say "System" out loud, so where that choice matters, compose SegmentedControl instead. Same values and the same setter, in a control that names each one, and iconOnly trades the labels for glyphs where space is tight. The button is the chrome form; this is the settings form.
In a menu
Where the theme lives in an account or overflow menu, this is the form: a Theme submenu naming all three values. Never the button, which would be the only unlabelled row in the column. A DropdownMenuSwitchItem is a boolean, so it spends system on the first flip.
Sizes
Three sizes. Pick the one that matches the icon buttons beside it: sm sits with icon-sm, the default with icon, lg with icon-lg.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| theme | Theme | null | None | The stored preference, or null until the client has read it. Drives the glyph: light is a sun, dark a moon, system a keyboard. Controlled, so pair it with onThemeChange. A value outside themes advances to the first entry, so the next click lands somewhere the button can reach. |
| resolvedTheme | "light" | "dark" | null | None | Optional. What is actually painted. The glyph no longer needs it, since system has a glyph of its own, but the accessible name reports it: a screen reader user otherwise learns that the theme follows the OS and never which way the OS went. |
| onThemeChange | (theme: Theme) => void | None | Called with the next theme in the cycle. Hand it straight to your theme library's setter. |
| themes | Theme[] | ["light", "dark"] | The cycle, in click order. Add "system" to make it a three-way; the button shows the keyboard glyph while system is the preference. |
| size | "sm" | "default" | "lg" | "default" | Box size on the action ramp: 24 / 32 / 40px, with a 16 / 20 / 24px glyph. The glyph is a larger share of the box than Button's, because a control with no fill has no visible box to sit inside. |
themeTheme | nullThe stored preference, or null until the client has read it. Drives the glyph: light is a sun, dark a moon, system a keyboard. Controlled, so pair it with onThemeChange. A value outside themes advances to the first entry, so the next click lands somewhere the button can reach. |
resolvedTheme"light" | "dark" | nullOptional. What is actually painted. The glyph no longer needs it, since system has a glyph of its own, but the accessible name reports it: a screen reader user otherwise learns that the theme follows the OS and never which way the OS went. |
| onThemeChange(theme: Theme) => voidCalled with the next theme in the cycle. Hand it straight to your theme library's setter. |
themesTheme[] = ["light", "dark"]The cycle, in click order. Add "system" to make it a three-way; the button shows the keyboard glyph while system is the preference. |
| size"sm" | "default" | "lg" = "default"Box size on the action ramp: 24 / 32 / 40px, with a 16 / 20 / 24px glyph. The glyph is a larger share of the box than Button's, because a control with no fill has no visible box to sit inside. |
"use client"
import * as React from "react"
import { IconKeyboard, IconMoon, IconSunHigh } from "@tabler/icons-react"
import { HeaderIconButton, type HeaderIconButtonSize } from "@/registry/ui/header-icon-button"
/* ─────────────────────────────────────────────────────────
* THREE ICONS, IMPORTED, NOT DRAWN
*
* This used to draw its own sun and moon and animate one into
* the other: a single circle that scaled up while a masking
* circle slid across it to carve a crescent, with the rays
* retracting into it. The path data was Tabler's, traced by
* hand, and it was still the wrong call. A hand-drawn copy of
* an icon has to be re-traced every time the set moves,
* nothing gates it against the real glyph, and it had already
* drifted into a different sun once with typecheck, verify-ui
* and the build all green through the whole thing.
*
* So the icons are imported. `IconSunHigh`,
* `IconMoon` and `IconKeyboard`, the same three
* the segmented and menu forms use, and the swap is instant.
* No crossfade either: a crossfade paints both glyphs at once
* mid-swap, which is what makes most theme toggles read as
* broken rather than as a change of state.
*
* OUTLINE, like every other kit icon. These were filled once,
* so the three theme forms matched each other, but a filled
* moon then sat beside the outline GitHub mark in the same
* header. One set everywhere matches both ways.
*
* WHY THIS IS CONTROLLED. It takes the theme as a prop and
* reports a change back, so it declares no theme library and
* ships in `kit` rather than `kit-extras`. A version that read
* `next-themes` itself would force that package on every
* install of the core, which would put the kit's only theme
* control behind the optional half of the system. It also
* keeps the glyph and the state machine apart: the kit owns
* the drawing, the app owns where the preference is stored.
* ───────────────────────────────────────────────────────── */
/** The stored preference. `system` means "follow the OS". */
export type Theme = "light" | "dark" | "system"
/** What is actually painted. Never `system`. */
export type ResolvedTheme = "light" | "dark"
/* The box, glyph size, press and focus ring all come from HeaderIconButton, so
the toggle and a GitHub link or menu button beside it are one control. */
type ThemeToggleSize = HeaderIconButtonSize
/* Keyed on the stored PREFERENCE, not on what is painted, which is why
`system` gets a glyph of its own rather than a sun or a moon wearing a
marker. It used to wear one: a dot on the corner, because a two-ended
animation had nowhere to put a third state. The dot was weak signage and the
docs said so. A keyboard says "whatever this machine is set to" outright,
and it is the same glyph the segmented and menu forms put on that row. */
const GLYPH: Record<Theme, typeof IconSunHigh> = {
light: IconSunHigh,
dark: IconMoon,
system: IconKeyboard,
}
const THEME_NOUN: Record<Theme, string> = {
light: "light",
dark: "dark",
system: "system",
}
/**
* A single-button theme control that cycles the stored preference and paints
* the kit's three theme icons. `system` is an opt-in third stop on the cycle.
*
* Controlled. Pass the stored preference as `theme` and store the value you get
* back from `onThemeChange`.
*
* PASS `null` FOR `theme` UNTIL IT IS KNOWN. Every theme library reads its
* stored value on the client, so the server renders one theme and the first
* client render has another. Guessing paints the wrong glyph and swaps it on
* load, which is a visible flip on every page view in the losing theme. `null`
* renders the box at full size with the glyph hidden and the control inert, so
* there is no layout shift, no wrong glyph, and no placeholder for the caller
* to build. Hold a post-mount flag and pass `null` until it flips.
*/
// @use-when switching light and dark from a header or other bare chrome; pass
// `themes` to add `system` as a third stop. Not inside a menu, where the form is
// a Theme submenu of radio items, and not on a settings surface, where it is a
// `SegmentedControl`: this button shows the three states but names none of them.
export function ThemeToggle({
theme,
resolvedTheme,
onThemeChange,
themes = ["light", "dark"],
size = "default",
className,
...props
}: Omit<React.ComponentProps<"button">, "onChange" | "type"> & {
/** `null` until the client has read the stored preference. */
theme: Theme | null
/**
* What is actually painted, if you have it. The glyph does not need it, since
* `system` has a glyph of its own, but the accessible name reports it: a
* screen reader user otherwise learns that the theme follows the OS and never
* which way the OS went.
*/
resolvedTheme?: ResolvedTheme | null
onThemeChange: (theme: Theme) => void
/**
* The cycle, in click order. The default is the plain light/dark swap. Pass
* `["light", "dark", "system"]` to let the same button hand the choice back
* to the OS, which is otherwise unreachable once someone has picked a side.
*/
themes?: Theme[]
size?: ThemeToggleSize
}) {
/* Unknown until the client has read the preference. The button still occupies
its full box, so revealing the glyph reflows nothing. */
const pending = theme === null
const isSystem = theme === "system"
/* A theme outside the cycle (the stored value is `system` but this button only
offers light and dark) lands on -1 and advances to the first entry, which is
the sane recovery: the next click puts the user back on a theme the button
can actually reach. */
const next = themes[(themes.indexOf(theme as Theme) + 1) % themes.length]
/* The GLYPH reports the state and the NAME reports the action, which is what
most theme controls ship. The name carries the current state too, because a
screen reader user cannot see the glyph and would otherwise never learn
which theme is on: an action-only name is a gap in the common version, not
a convention worth keeping. `title` stays short, since a sighted visitor
already has the glyph.
No `aria-pressed`. It declares a toggle, whose name is meant to hold still
while the pressed state changes, so pairing it with an action name
announces "Switch to light theme, pressed" and reports the switch as
already done. With a third stop there is no boolean to press anyway. */
const stateNoun =
isSystem && resolvedTheme ? `system (${resolvedTheme})` : THEME_NOUN[theme as Theme]
const action = `Switch to ${THEME_NOUN[next]} theme`
const Glyph = pending ? null : GLYPH[theme as Theme]
return (
<HeaderIconButton
onClick={() => onThemeChange(next)}
data-slot="theme-toggle"
data-theme-preference={theme ?? undefined}
/* Inert and unnamed while pending: before the preference is read there is
no control here yet, only the space one will occupy, and a button that
announces a theme it does not know is worse than one that is absent. */
disabled={pending}
aria-hidden={pending || undefined}
aria-label={pending ? undefined : `Theme: ${stateNoun}. ${action}.`}
title={pending ? undefined : action}
size={size}
className={className}
{...props}
>
{/* Nothing is drawn while pending, rather than drawn and hidden, so the
box holds its size and the first real render puts the right glyph
straight in. */}
{Glyph ? <Glyph aria-hidden /> : null}
</HeaderIconButton>
)
}