# Theme Toggle

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

- Type: React component (Buttons & Actions)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add theme-toggle`
- Docs: https://wingo-ui.com/components/theme-toggle

## Interaction

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.

## Installation

```bash
npx wingo-ui@latest add theme-toggle
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/theme-toggle` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/theme-toggle.tsx`

npm dependencies: `npm i lucide-react motion next-themes`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Tooltip](https://wingo-ui.com/components/tooltip) ([Markdown](https://wingo-ui.com/components/tooltip.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [useShortcut](https://wingo-ui.com/components/use-shortcut) ([Markdown](https://wingo-ui.com/components/use-shortcut.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
"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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant?` | `"icon" \| "segmented" \| "dropdown"` | `"icon"` | 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). |
| `includeSystem?` | `boolean` | `true` | Offers 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. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | 32 / 40 / 48px, like the button (md is 44px on touch). The glyph is 16 / 18 / 20px; the segmented track and the menu follow. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"full"` | Corner shape of the icon button, the dropdown trigger and the segmented track. Scales with the size. |
| `buttonVariant?` | `"solid" \| "soft" \| "outline" \| "ghost" \| "link"` | `"ghost"` | The look of the icon button and the dropdown trigger. ghost sits quietly in a header. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Color of the icon button and the dropdown trigger, and the fill of the segmented thumb (neutral is a raised white thumb). |
| `color?` | `string` | `""` | Any CSS color; replaces the tone. Leave empty to use the tone. |
| `showLabels?` | `boolean` | `false` | segmented: icons with labels (icons only by default, each with a tooltip). dropdown: the current theme's name and a chevron on the trigger. |
| `fullWidth?` | `boolean` | `false` | segmented: the segments share the full width of the parent, the usual layout for a settings row on a phone. |
| `transition?` | `"none" \| "fade" \| "circle"` | `"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. |
| `duration?` | `number` | `500` | Length of the reveal in ms. |
| `shortcut?` | `string` | `""` | A key combo that flips light / dark from anywhere, e.g. "shift+t". Shown in the tooltip and set as aria-keyshortcuts. Empty binds nothing. |
| `disabled?` | `boolean` | `false` | Dims the control and blocks it. It is also disabled on pages that force a theme (next-themes forcedTheme). |
| `defaultOpen?` | `boolean` | `false` | dropdown: starts with the menu open. |
| `align?` | `"start" \| "center" \| "end"` | `"end"` | dropdown: which edge of the menu lines up with the trigger. end suits a toggle at the end of a header. |
| `responsive?` | `boolean` | `true` | dropdown: 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. |
| `defaultValue?` | `"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) => void` |  | Fires with every pick, inside the view transition, so the new theme you apply is what the reveal shows. |
| `open?` | `boolean` |  | dropdown: controls the menu. |
| `onOpenChange?` | `(open: boolean) => void` |  | dropdown: 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". |
| `classNames?` | `Partial<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. |
| `className?` | `string` |  | Extra classes for the root: the button, the segmented track or the dropdown trigger. |

## Parts

### useThemeToggle (hook)

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

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `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) => void` |  | Fires with every pick, inside the view transition. |
| `transition?` | `"none" \| "fade" \| "circle"` | `"circle"` | How the new theme comes in. |
| `duration?` | `number` | `500` | Milliseconds 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 (function)

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

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `apply` | `() => void` |  | Applies the theme, e.g. () => setTheme("dark"). |
| `options.transition?` | `"none" \| "fade" \| "circle"` | `"circle"` | The reveal style. |
| `options.duration?` | `number` | `500` | Milliseconds of the reveal. |
| `options.origin?` | `Element \| { x: number; y: number }` |  | Where the circle starts; defaults to the top end corner. |

### DEFAULT_THEME_TOGGLE_LABELS (constant)

The English strings: Light, Dark, System, Toggle theme, Theme and the three screen reader announcements. Spread it to translate.

### ThemeValue (type)

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

## See also

- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [User Menu](https://wingo-ui.com/components/user-menu) ([Markdown](https://wingo-ui.com/components/user-menu.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))

---

Canonical: https://wingo-ui.com/components/theme-toggle
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
