Theme Toggle React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React theme toggle for light, dark and system mode on next-themes: an animated sun and moon button, a segmented control or a menu.
Press the icon button and the new theme grows over the page in a circle from the button, while the glyph morphs: going dark the sun's rays fold away, the disc swells and a shadow slides across it until a crescent moon is left; going light it runs backwards. The button cycles System, then the theme the device is not showing, then the device's own theme, then System again (a small monitor badge appears in the corner and the page follows the device), so from System the first press always changes what you see. With includeSystem off it flips between light and dark and never shows the badge. Hover or focus shows a tooltip with the current theme and the shortcut. The segmented variant slides its raised thumb to Light, Dark or System; the dropdown opens a menu with the three choices and a check on the current one, as a bottom sheet on phones. Keyboard: Tab to it, Enter or Space toggles the icon button; in the segmented control Left / Right (and Home / End) pick a theme; in the menu Up / Down move, Enter picks, Escape closes and focus returns to the button. A shortcut prop such as shift+t flips light and dark from anywhere. The new theme is announced to screen readers. With reduced motion the glyph crossfades and the page switches at once. On a phone every target is 44px.
Theme Toggle is a Pro React component in the React button components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add theme-toggle, together with Button, Dropdown Menu, Segmented Control, Tooltip, useControllableState and 5 more and the npm packages lucide-react, motion and next-themes.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add theme-toggleTheme Toggle is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import ThemeToggle from @/components/ui/theme-toggle.
"use client"
import { ThemeProvider } from "next-themes"
import { ThemeToggle } from "@/components/ui/theme-toggle"
// next-themes needs attribute="class" so the dark: variant and the reveal see the change
export function AppHeader() {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<header className="flex h-14 items-center justify-between px-4">
<span className="font-semibold">Northwind CRM</span>
<ThemeToggle
shortcut="shift+t"
/>
</header>
</ThemeProvider>
)
}Props
variantdefault"icon""icon" | "segmented" | "dropdown"icon: an animated sun / moon button for headers. segmented: Light / Dark / System side by side for settings pages. dropdown: a button that opens a menu (a sheet on phones).
includeSystemdefaulttruebooleanOffers System, which follows the device. The icon button then cycles system -> the opposite of the device -> the device's theme -> system, so each press but the last visibly changes the page; without it the button flips light <-> dark, and a page on system shows as the theme it resolves to (no badge). Segmented and dropdown show a third option.
sizedefault"md""sm" | "md" | "lg"32 / 40 / 48px, like the button (md is 44px on touch). The glyph is 16 / 18 / 20px; the segmented track and the menu follow.
radiusdefault"full""none" | "sm" | "md" | "lg" | "full"Corner shape of the icon button, the dropdown trigger and the segmented track. Scales with the size.
buttonVariantdefault"ghost""solid" | "soft" | "outline" | "ghost" | "link"The look of the icon button and the dropdown trigger. ghost sits quietly in a header.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"Color of the icon button and the dropdown trigger, and the fill of the segmented thumb (neutral is a raised white thumb).
colordefault""stringAny CSS color; replaces the tone. Leave empty to use the tone.
showLabelsdefaultfalsebooleansegmented: icons with labels (icons only by default, each with a tooltip). dropdown: the current theme's name and a chevron on the trigger.
fullWidthdefaultfalsebooleansegmented: the segments share the full width of the parent, the usual layout for a settings row on a phone.
transitiondefault"circle""none" | "fade" | "circle"How the new theme appears: a circle growing from the control, a crossfade, or at once. Uses the View Transitions API (Chrome, Edge, Safari 18+), falls back to switching at once, and is off under reduced motion. The file also exports switchThemeWithTransition(apply, { transition?, duration?, origin? }), which runs any theme change inside the same reveal (origin is an element or a { x, y } point) and skips it while another one runs.
durationdefault500numberLength of the reveal in ms.
shortcutdefault""stringA key combo that flips light / dark from anywhere, e.g. "shift+t". Shown in the tooltip and set as aria-keyshortcuts. Empty binds nothing.
disableddefaultfalsebooleanDims the control and blocks it. It is also disabled on pages that force a theme (next-themes forcedTheme).
defaultOpendefaultfalsebooleandropdown: starts with the menu open.
aligndefault"end""start" | "center" | "end"dropdown: which edge of the menu lines up with the trigger. end suits a toggle at the end of a header.
responsivedefaulttruebooleandropdown: the menu becomes a bottom sheet below 768px.
value"light" | "dark" | "system"Controlled use without next-themes: the toggle shows this theme and only calls onValueChange. Left out, it reads and writes next-themes' useTheme(). For your own UI, useThemeToggle (see Parts) gives the same logic without the button.
defaultValuedefault"""light" | "dark" | "system"Uncontrolled use without next-themes: the starting theme. The toggle keeps its own state and reports picks through onValueChange. Left out (with value), it reads and writes next-themes; the playground's empty option means left out.
onValueChange(theme: ThemeValue) => voidFires with every pick, inside the view transition, so the new theme you apply is what the reveal shows.
openbooleandropdown: controls the menu.
onOpenChange(open: boolean) => voiddropdown: fires when the menu opens or closes.
labels{ light?, dark?, system?, toggle?, menu?, announceLight?, announceDark?, announceSystem? }Every string, merged over DEFAULT_THEME_TOGGLE_LABELS: "Light", "Dark", "System", "Toggle theme" (the icon button's name), "Theme" (group name, tooltip prefix, sheet title) and the screen reader announcements "Light theme", "Dark theme", "System theme".
classNamesPartial<Record<"icon" | "badge" | "label" | "chevron" | "item" | "thumb" | "content", string>>Extra classes for inner parts: the glyph, the system badge, the trigger label and chevron, segments or menu rows, the segmented thumb, the menu surface.
classNamestringExtra classes for the root: the button, the segmented track or the dropdown trigger.
Parts
useThemeToggle
hookThe toggle's logic without its UI, for user menus and settings pages. Reads and writes next-themes, or a value / onValueChange pair, and runs the reveal from an element or a point.
value"light" | "dark" | "system"Controlled theme without next-themes; picks only call onValueChange.
defaultValue"light" | "dark" | "system"Uncontrolled starting theme without next-themes.
onValueChange(theme: ThemeValue) => voidFires with every pick, inside the view transition.
transitiondefault"circle""none" | "fade" | "circle"How the new theme comes in.
durationdefault500numberMilliseconds of the reveal.
returns{ theme, resolvedTheme, setTheme(theme, origin?), toggle(origin?), mounted, forced }theme and resolvedTheme stay undefined until mounted when they come from next-themes; forced is true when the page pins a theme.
switchThemeWithTransition
functionRuns your theme change inside a view transition, so the new theme grows as a circle from a button or fades in. Falls back to an instant switch without view transitions or with reduced motion.
apply*() => voidApplies the theme, e.g. () => setTheme("dark").
options.transitiondefault"circle""none" | "fade" | "circle"The reveal style.
options.durationdefault500numberMilliseconds of the reveal.
options.originElement | { x: number; y: number }Where the circle starts; defaults to the top end corner.
DEFAULT_THEME_TOGGLE_LABELS
constantThe English strings: Light, Dark, System, Toggle theme, Theme and the three screen reader announcements. Spread it to translate.
ThemeValue
typeThe theme a person picks: "light" | "dark" | "system".
Examples
Three variants
An icon button for headers, a segmented control for settings, a menu when space is tight.
Settings row
Segmented with labels in an Appearance row; on a phone it stacks under the text and fills the row (fullWidth).
Light and dark only, brand tone
includeSystem off flips between two themes; ghost, soft and solid brand buttons in three sizes.
Transitions compared
At once, a crossfade, or a circle that grows out of the button.
In a user menu
useThemeToggle wires the theme row of a user menu; the reveal starts where you tapped.
Controlled, without next-themes
value and onValueChange drive a box that flips its own .dark class; the page stays as it is.
Dependencies
Files
- components/ui/theme-toggle.tsx
FAQ
Is the React Theme Toggle component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Theme Toggle?
Run npx wingo-ui@latest add theme-toggle. It also adds the Wingo UI items it builds on (Button, Dropdown Menu, Segmented Control, Tooltip, useControllableState and 5 more) and the npm packages lucide-react, motion and next-themes. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/theme-toggle.
How does Theme Toggle behave on phones?
dropdown: the menu becomes a bottom sheet below 768px. The responsive prop controls this and is on by default.
See also
Used in
More React button components
Articles using this component
- next-themes Hydration Error: Fix the Flash in Next.js
Next.js and Tailwind CSS8 min read
- Next.js Component Library Setup for the App Router
Next.js and Tailwind CSS8 min read
- Tailwind v4 Dark Mode: Class Toggle, Tokens, next-themes
Next.js and Tailwind CSS9 min read