Buttons

Theme Toggle

A sun/moon theme toggle button with a rotate-and-scale cross-fade — fully controlled and host-agnostic, wire it to any theme provider.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { cn } from "@/lib/utils"

const SIZES = {
  sm: { button: "size-8", icon: "size-4" },
  md: { button: "size-10", icon: "size-5" },
  lg: { button: "size-12", icon: "size-6" },
} as const

export interface ThemeToggleButtonProps
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onToggle"> {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/theme-toggle-button.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ThemeToggleButton" component using
lucide-react for the Sun and Moon icons.

Contract
- Export a forwardRef<HTMLButtonElement> component extending
  React.ButtonHTMLAttributes (omit "children" and the native "onToggle").
- Fully controlled and host-agnostic: theme: "light" | "dark" (required),
  onToggle: (next: "light" | "dark") => void (required),
  size?: "sm" | "md" | "lg" (default "md"), className.
- Do NOT import next-themes or any theme library — the host owns the state.
  Clicking calls onToggle with the opposite of the current theme, then runs
  the consumer's own onClick.

Behavior
- Sun and Moon are stacked in the same grid cell (inline-grid on the button,
  both icons col-start-1 row-start-1) so the button never shifts size
  mid-transition.
- Active icon: rotate-0 scale-100 opacity-100. Inactive icon: scale-0
  opacity-0 rotated a quarter turn (Sun -90deg, Moon +90deg) — toggling reads
  as a rotate + scale cross-fade driven by one plain CSS transition-all at
  ~300ms. No @keyframes, no animation library.
- Under prefers-reduced-motion the icons swap instantly
  (motion-reduce:transition-none); toggling itself is unaffected.
- type="button" so it never submits a surrounding form.

Rendering & styling
- Circular ghost button: rounded-full border bg-background text-foreground
  hover:bg-muted, focus-visible ring (ring-ring), disabled:opacity-50 —
  semantic tokens only, no hardcoded colors, dark mode comes free.
- Size map pairs button and icon: sm = size-8 / size-4, md = size-10 /
  size-5, lg = size-12 / size-6.
- Dynamic aria-label ("Switch to dark mode" when light, "Switch to light
  mode" when dark); both icons aria-hidden. No aria-pressed — for a
  two-state control the action-naming label carries the state.
- Merge consumer className via cn().

Customization levers
- Wire to next-themes in two lines:
  const { resolvedTheme, setTheme } = useTheme()
  <ThemeToggleButton theme={resolvedTheme === "dark" ? "dark" : "light"}
    onToggle={setTheme} />
  (render after mount to avoid the usual next-themes hydration mismatch).
- Transition feel: duration-300 is the single knob (200–500ms is the useful
  range); drop the opacity classes for a harder, more mechanical swap.
- Shape / skin: swap rounded-full for rounded-lg, or replace border +
  bg-background with bg-transparent for a naked ghost — tokens only.
- Size scale: extend the SIZES map; keep the icon roughly half the button.
- Progressive upgrade: wrap the onToggle call in
  document.startViewTransition for a page-level circular reveal — the
  contract is unchanged, so the upgrade is purely additive.

Concepts

  • Controlled toggle — the button holds zero theme state: it renders theme and emits onToggle(next), so the same component sits on top of next-themes, a Zustand store, or a scoped local state without adapters.
  • Host-agnostic theming — no theme-library import means no framework lock-in; the two-line next-themes wiring (or any setter) is the only integration surface.
  • Icon cross-fade — both icons occupy the same grid cell with opposite rotate/scale/opacity endpoints, so a single CSS transition reads as one icon morphing into the other — no keyframes, no JS animation.
  • Label-as-state accessibility — the dynamic aria-label names the action ("Switch to dark mode"), which is why a two-state toggle needs no aria-pressed: the label alone tells a screen reader where the switch will go.
  • Reduced-motion honestymotion-reduce:transition-none swaps icons instantly while keeping the toggle fully functional; decoration degrades, behavior never does.
  • View-transition upgrade path — because the state lives in the host, wrapping the setter in document.startViewTransition adds a page-level reveal effect without touching the component's contract.

On This Page