Theme switcher

Four smooth ways to move between light and dark appearance.

themeappearancemotion
Preview
Theme switcher preview

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

Keyboard interactions

KeysAction
Enter / SpaceToggles 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).