Skip to content
Component

Theme switch

A button that toggles light and dark themes with a rotating icon swap.

  • #arc
  • #component
  • #theme
  • #dark-mode
  • #toggle
  • #button

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/theme-switch.json

theme-switch.module.csscss
/* Each transition uses the same library Button, which owns the press scale; this file only animates the icon in place. */
.themeSwitch {
  position: relative;
  width: auto;
  padding-inline: var(--space-3);
}
.iconOnly { width: 38px; min-width: 38px; height: 38px; min-height: 38px; border-radius: 12px; padding: 0; }
@media (hover: hover) and (pointer: fine) {
  .themeSwitch:hover:not(:disabled) { border-color: var(--border-strong); }
  .iconOnly:hover:not(:disabled) { background: var(--surface-muted); box-shadow: none; }
}
.iconWrap {
  position: relative;
  display: inline-grid;
  width: 20px;
  height: 20px;
  flex: 0 0 20px;
  place-items: center;
}
/* The nudge only eases once the switch has settled, so a theme that arrives during hydration lands without motion. */
.settled .iconWrap { transition: transform var(--duration-spring) var(--ease-spring); }
.icon { position: absolute; display: grid; width: 18px; height: 18px; place-items: center; }
.label { white-space: nowrap; }
/* Each variant nudges the icon the way its page transition travels, and eases back when the theme returns. */
/* Not `data-theme`: that attribute re-scopes the page tokens (a page stylesheet may define `[data-theme="dark"]`), which turned this button dark on a light page. */
.themeSwitch.reveal[data-mode="dark"] .iconWrap { transform: scale(1.08); }
.themeSwitch.eclipse[data-mode="dark"] .iconWrap { transform: translateX(-2px); }
.themeSwitch.split[data-mode="dark"] .iconWrap { transform: translateX(1px); }
.themeSwitch.rise[data-mode="dark"] .iconWrap { transform: translateY(-2px); }
@media (prefers-reduced-motion: reduce) {
  .settled .iconWrap { transition: none; }
}
theme-switch.tsxtsx
"use client";

import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import { Button } from "../button/button";
import styles from "./theme-switch.module.css";
import { UiIcon } from "../theme/ui-icon";

export type ThemeSwitchVariant = "reveal" | "eclipse" | "split" | "rise";
export type Theme = "light" | "dark";

export interface ThemeSwitchProps {
  theme: Theme;
  variant?: ThemeSwitchVariant;
  onThemeChange: (next: Theme, variant: ThemeSwitchVariant, trigger: HTMLElement) => void;
  label?: string;
  iconOnly?: boolean;
}

/** Rotation and scale ride the spring; opacity and blur tween so the blur never overshoots below zero. */
const iconSpring = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }, filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } as const;
const iconExit = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } as const;
const blur = `blur(${motionTokens.blur.subtle}px)`;

/** A stored preference usually reaches the theme prop just after hydration. That correction swaps the icon in place;
 *  only changes after the first painted frames animate, so a page that loads in dark mode never spins its switches. */
function useSettled() {
  const [settled, setSettled] = useState(false);
  useEffect(() => {
    let second = 0;
    const first = requestAnimationFrame(() => { second = requestAnimationFrame(() => setSettled(true)); });
    return () => { cancelAnimationFrame(first); cancelAnimationFrame(second); };
  }, []);
  return settled;
}

/** Both icons turn the same way (clockwise into dark, back out of it), so the swap reads as one rotation rather than two fades. */
function ThemeIcon({ theme, reduced, settled }: { theme: Theme; reduced: boolean; settled: boolean }) {
  const icon = theme === "light" ? "theme-light" : "theme-dark";
  const angle = theme === "light" ? 30 : -30;
  return (
    <AnimatePresence initial={false} mode="popLayout">
      <motion.span
        key={theme}
        className={styles.icon}
        initial={!settled ? false : reduced ? { opacity: 0 } : { opacity: 0, scale: .7, rotate: angle, filter: blur }}
        animate={{ opacity: 1, scale: 1, rotate: 0, filter: "blur(0px)" }}
        exit={!settled ? { opacity: 0, transition: { duration: 0 } } : reduced ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : { opacity: 0, scale: .7, rotate: angle, filter: blur, transition: iconExit }}
        transition={reduced ? { duration: motionTokens.duration.instant } : iconSpring}
        aria-hidden="true"
      >
        <UiIcon iconRole={icon} size={16} strokeWidth={1.75} />
      </motion.span>
    </AnimatePresence>
  );
}

export function ThemeSwitch({ theme, variant = "reveal", onThemeChange, label, iconOnly = false }: ThemeSwitchProps) {
  const reduced = useReducedMotion() ?? false;
  const settled = useSettled();
  const next = theme === "light" ? "dark" : "light";
  const classes = [styles.themeSwitch, styles[variant], iconOnly ? styles.iconOnly : "", settled ? styles.settled : ""].join(" ");

  return (
    <Button
      type="button"
      size="sm"
      variant="secondary"
      className={classes}
      data-mode={theme}
      // A fixed name with aria-pressed (on = dark): an action-style name together with a pressed state reads as "switch to dark mode, pressed".
      aria-label={label ?? "다크 모드"}
      aria-pressed={theme === "dark"}
      onClick={event => onThemeChange(next, variant, event.currentTarget)}
    >
      <span className={styles.iconWrap}><ThemeIcon theme={theme} reduced={reduced} settled={settled} /></span>
      {!iconOnly && <span className={styles.label}>테마 바꾸기</span>}
    </Button>
  );
}

export default ThemeSwitch;

Used items