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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/theme-toggle-button.jsonPrompt
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
themeand emitsonToggle(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 honesty —
motion-reduce:transition-noneswaps 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.startViewTransitionadds a page-level reveal effect without touching the component's contract.