Theme switcher
Four smooth ways to move between light and dark appearance.
themeappearancemotion
Preview
Theme switcher preview
Live · keyboard ready
When to use
- A single light and dark toggle in an app or marketing top bar.
- Pages that want to run their own view transition from the button's position.
When not to use
- Use user-menu when theme choice, including system, belongs inside an account menu.
- Use theme-switch-rise for quiet product chrome, or theme-switch-eclipse for a dramatic showcase change.
shadcn CLI
Also installs (2)
- manicat-foundation
- button
Manual install(+)
registry.jsonjson
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "theme-switch",
"type": "registry:ui",
"title": "Theme switcher",
"description": "Four smooth ways to move between light and dark appearance.",
"categories": [
"actions"
],
"dependencies": [
"lucide-react",
"motion"
],
"registryDependencies": [
"https://manicat.viraza.net/r/manicat-foundation.json",
"https://manicat.viraza.net/r/button.json"
],
"files": [
{
"path": "registry/components/theme-switch/theme-switch.tsx",
"type": "registry:component",
"target": "@components/manicat/theme-switch/theme-switch.tsx"
},
{
"path": "registry/components/theme-switch/theme-switch.module.css",
"type": "registry:component",
"target": "@components/manicat/theme-switch/theme-switch.module.css"
},
{
"path": "registry/components/button/button.tsx",
"type": "registry:component",
"target": "@components/manicat/button/button.tsx"
},
{
"path": "registry/components/button/button.module.css",
"type": "registry:component",
"target": "@components/manicat/button/button.module.css"
}
],
"docs": "Docs and live preview: https://uiarc.dev/components/theme-switch",
"meta": {
"tier": "free",
"kind": "component",
"docs": "https://manicat.viraza.net/components/theme-switch",
"markdown": "https://uiarc.dev/components/theme-switch/markdown",
"tags": [
"theme",
"appearance",
"motion"
]
}
}Usage
Install the component, then copy this into your app. Change the content and the props to fit your screen.
example.tsxtsx
"use client";
import { useEffect, useState } from "react";
import { ThemeSwitch } from "@/components/manicat/theme-switch/theme-switch";
import { useTheme } from "fumadocs-ui/provider/base";
function useMounted() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
const frame = requestAnimationFrame(() => setMounted(true));
return () => cancelAnimationFrame(frame);
}, []);
return mounted;
}
export function ThemeSwitchExample() {
const { resolvedTheme, setTheme } = useTheme();
const mounted = useMounted();
// next-themes only knows the stored theme on the client, so rendering from resolvedTheme before
// hydration settles would mismatch the server's cookie value. Wait for mount, then it's stable.
if (!mounted) return null;
return (
<div className="flex flex-wrap items-center gap-3">
<ThemeSwitch theme={resolvedTheme === "dark" ? "dark" : "light"} onThemeChange={(next) => setTheme(next)} />
</div>
);
}
Examples
Real screens rather than isolated props. Switch to Code to copy a whole example into your project.
Header theme toggle Keep the theme in state (or in next-themes) and pass it in; the switch animates the change.
Header theme toggle
Keep the theme in state (or in next-themes) and pass it in; the switch animates the change. preview
Live · keyboard ready
header-theme-toggle-keep-the-theme-in-state-or-in-next-themes-and-pass-it-in-the-switch-animates-the-change-.tsxtsx
"use client";
import { useState } from "react";
import { ThemeSwitch } from "@/components/manicat/theme-switch/theme-switch";
export function ThemeSwitchHeaderExample() {
const [theme, setTheme] = useState<"light" | "dark">("light");
return <ThemeSwitch theme={theme} onThemeChange={setTheme} />;
}
Keyboard interactions
| Keys | Action |
|---|---|
| Enter / Space | Toggles the theme. |
Accessibility
- Renders the library Button with aria-pressed set when the theme is dark.
- aria-label defaults to "Switch to dark mode" or "Switch to light mode"; pass label to override.
- The icons are aria-hidden.
Motion
- Sun and moon trade places with a shared rotation, scale, and blur on a snappy spring; the icon also nudges in the direction of the chosen variant.
- A theme that arrives during hydration swaps without motion, so a dark page never spins its switch on load.
- Reduced motion swaps the icon with an instant fade and removes the nudge; skip the page transition too when reduced.
Responsive behavior
- The labelled button fits a desktop top bar; pass iconOnly for a fixed 38px square on narrow headers and mobile.
- Hover styles apply only on hover-capable fine pointers, so touch taps do not leave a stuck hover state.
Performance
- The button itself is tiny: one icon swap on a spring, and the first theme after hydration applies without animating.
- The page transition is yours; a full-page view transition snapshots the whole document, so keep it short and skip it for reduced motion.
Notes for AI
- Use for a single light/dark toggle in a top bar. For light, dark, and system inside an account menu use user-menu.
- The component only reports the change. Apply the theme in onThemeChange, typically inside document.startViewTransition, and use trigger's rect as the transition origin.
- variant is passed back to onThemeChange so one handler can run the matching page transition (reveal, eclipse, split, rise).