Theme Toggle
A single-button theme control that cycles the stored preference and paints the kit's three theme icons.
Installation
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
| Prop | Type | Default | Description |
|---|---|---|---|
| theme | Theme | 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. |
| 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. |
"use client"
import * as React from "react"
import { IconKeyboardFilled, IconMoonFilled, IconSunHighFilled } from "@tabler/icons-react"
import { cn } from "@/registry/lib/utils"
/* ─────────────────────────────────────────────────────────
* 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. `IconSunHighFilled`,
* `IconMoonFilled` and `IconKeyboardFilled`, 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.
*
* FILLED, which is the one place the kit leaves its outline
* set. All three theme forms use one set, so the choice is
* made here and in the rules file rather than per call site.
*
* 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"
type ThemeToggleSize = "sm" | "default" | "lg"
/* The BOX reads the icon-only rungs of the kit's action ramp (Button's
`icon-sm` / `icon` / `icon-lg`, 24 / 32 / 40), so a toggle in a header takes
the same row height as the icon button beside it.
The GLYPH does NOT read Button's ramp, and that is the whole point. Button
paints a fill, so its 12 / 14 / 16 sits inside a VISIBLE pill and the gap
around it is the pill's padding. This control paints no fill: the box is
invisible and exists only as a hit area, so the glyph carries the entire
read and Button's ratio just renders a small mark floating in nothing. The
first version shipped that mistake, which put a 14px glyph between the docs
header's two 20px ones in identical 32px boxes.
These are ~62% of the box, which is where the kit's other bare icon controls
already sit: the header's GitHub and menu icons are 20 in 32, and a carousel
`bare` arrow is 24 in 32. `default` is deliberately 20 in 32, exactly its
neighbours, and the other two rungs scale from it. Pushing the glyph past
this was tried and reverted on 2026-09-03: at 80% the sun reads as an
oversized mark rather than as chrome, and a 40px default made the header
taller than the search field beside it.
`tap-target` rather than `pointer-coarse:size-11`: the utility grows the hit
area with a pseudo-element, so a coarse pointer gets its 44px without the bar
reflowing around a control that suddenly measures 44 instead of 32. */
const SIZE: Record<ThemeToggleSize, { button: string; glyph: string }> = {
sm: { button: "size-6 rounded-action-xs", glyph: "size-4" },
default: { button: "size-8 rounded-action", glyph: "size-5" },
lg: { button: "size-10 rounded-action-lg", glyph: "size-6" },
}
/* 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 IconSunHighFilled> = {
light: IconSunHighFilled,
dark: IconMoonFilled,
system: IconKeyboardFilled,
}
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
}) {
const s = SIZE[size]
/* 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 (
<button
type="button"
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}
className={cn(
"tap-target inline-flex shrink-0 cursor-pointer items-center justify-center text-muted-foreground",
// `transform` is IN the transition list so the press eases the way a
// Button's does; left out, the scale snapped down and back while the
// button beside it eased over the same 150ms. This is the button's
// press and its hover colour, the same feedback every other control in
// the kit gives. The GLYPH itself never animates: it is swapped, not
// transitioned. The `motion-reduce` list drops transform and keeps
// colour, which is the split the rule asks for: a colour change is
// non-vestibular and carries meaning, movement does not.
// `active:scale-100` then removes the press entirely, since dropping it
// from the transition list would only make it instant.
"transition-[transform,color] duration-150 motion-reduce:transition-[color] hover:text-foreground",
"outline-none focus-visible:ring-ring-focus focus-visible:ring-[3px]",
// 0.98 is the kit's press depth, on Button's eleven variants and near
// enough SegmentedControl's 0.97. This shipped at 0.95, inherited from
// a one-off, which is more than twice the travel of the buttons it sits
// beside.
"active:scale-[0.98] motion-reduce:active:scale-100",
s.button,
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 className={s.glyph} aria-hidden /> : null}
</button>
)
}