UI.MD

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.json

Usage

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

Boxes of 24, 32 and 40px, matching Button's icon-sm, icon and icon-lg so the control takes the same row height. The glyphs run larger than Button's, at 16, 20 and 24: this one paints no fill, so the box is invisible and the glyph carries the whole read.

API Reference

PropTypeDefaultDescription
themeTheme | null-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-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-Called 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.