{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "theme-toggle",
  "type": "registry:ui",
  "title": "Theme Toggle",
  "description": "An accessible React dark mode toggle for Next.js that reveals the new theme from the button with a View Transition: a circle, ink, a wave or a page turn.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "motion@^14.0.0",
    "next-themes@^0.4.6"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json",
    "https://ui.fewtone.com/r/button.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/theme-toggle.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { Button, type ButtonProps } from \"@/components/ui/button\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { motion, type Transition, useReducedMotion } from \"motion/react\";\nimport { useTheme } from \"next-themes\";\nimport { useId, useSyncExternalStore } from \"react\";\nimport { flushSync } from \"react-dom\";\n\n/*\n * The View Transitions API snapshots the page, the theme changes underneath, and the new theme is revealed\n * over the old one. Background, text and every component change together inside the moving edge. Each\n * reveal is a set of Web Animations keyframes for the two snapshots, built from the click point and the\n * screen size. Without browser support, or with reduced motion, the switch is instant.\n */\n\n/** Every reveal, in the order the docs list them */\nconst themeReveals = [\n  \"circle\",\n  \"shrink\",\n  \"ink\",\n  \"tide\",\n  \"page-turn\",\n  \"fade\",\n  \"wipe-right\",\n  \"wipe-down\",\n  \"feather\",\n  \"curtain\",\n  \"diagonal\",\n] as const;\n\nexport type ThemeReveal = (typeof themeReveals)[number];\nexport type ThemeRevealSpeed = \"quick\" | \"medium\" | \"slow\";\n\n/** Reveal lengths in milliseconds */\nconst revealSpeeds: Record<ThemeRevealSpeed, number> = { quick: 450, medium: 700, slow: 1000 };\n\ntype Point = { x: number; y: number };\n/** The click point, the screen, and the radius from the click to the farthest corner */\ntype Stage = { origin: Point; width: number; height: number; radius: number };\n/** Keyframes for the new and old snapshots; `oldOnTop` when the old one moves away to uncover the new */\ntype Reveal = { new?: Keyframe[]; old?: Keyframe[]; oldOnTop?: boolean };\n\nconst TAU = Math.PI * 2;\nconst px = (value: number) => `${value.toFixed(1)}px`;\nconst polygon = (points: Point[]) => `polygon(${points.map((p) => `${px(p.x)} ${px(p.y)}`).join(\", \")})`;\nconst circle = (r: number, o: Point) => `circle(${px(r)} at ${px(o.x)} ${px(o.y)})`;\n/** `count` evenly spaced keyframes, each made from its progress t (0 to 1) */\nconst frames = (count: number, make: (t: number) => Keyframe) =>\n  Array.from({ length: count }, (_, index) => make(index / (count - 1)));\n\nconst reveals: Record<ThemeReveal, (stage: Stage) => Reveal> = {\n  // A circle grows from the click until it passes the farthest corner\n  circle: ({ origin, radius }) => ({\n    new: [{ clipPath: circle(0, origin) }, { clipPath: circle(radius, origin) }],\n  }),\n\n  // The old theme closes into the click, uncovering the new one behind it\n  shrink: ({ origin, radius }) => ({\n    oldOnTop: true,\n    old: [{ clipPath: circle(radius, origin) }, { clipPath: circle(0, origin) }],\n  }),\n\n  // A blob whose radius wobbles with two sine waves that drift as it grows. Its narrowest point is 80% of\n  // the widest, so the end size is set for that point to pass the farthest corner.\n  ink: ({ origin, radius }) => {\n    const reach = (radius / 0.8) * 1.03;\n    return {\n      new: frames(20, (t) => ({\n        clipPath: polygon(\n          Array.from({ length: 64 }, (_, index) => {\n            const angle = (index / 64) * TAU;\n            const phase = t * Math.PI * 1.6;\n            const r =\n              reach * t * (1 + 0.12 * Math.sin(3 * angle + phase) + 0.08 * Math.sin(5 * angle - phase * 1.3));\n            return { x: origin.x + r * Math.cos(angle), y: origin.y + r * Math.sin(angle) };\n          }),\n        ),\n      })),\n    };\n  },\n\n  // A wave edge rises from below the screen to above it. The swell is largest half-way, and two harmonics\n  // travel sideways at different speeds so it rolls like liquid rather than sliding.\n  tide: ({ width, height }) => {\n    const swell = height * 0.06;\n    const margin = swell * 1.2;\n    return {\n      new: frames(28, (t) => {\n        const amplitude = swell * (Math.sin(Math.PI * t) + 0.15);\n        const level = height + margin - t * (height + 2 * margin);\n        const phase = t * TAU * 1.2;\n        const wave = Array.from({ length: 49 }, (_, index) => {\n          const x = (index / 48) * width;\n          const k = (x / width) * TAU;\n          return {\n            x,\n            y: level + amplitude * (0.7 * Math.sin(1.5 * k + phase) + 0.3 * Math.sin(3.2 * k - phase * 1.4)),\n          };\n        });\n        return { clipPath: polygon([...wave, { x: width, y: height * 2 }, { x: 0, y: height * 2 }]) };\n      }),\n    };\n  },\n\n  // The old page turns on its left edge until it's side-on, fading over the last quarter\n  \"page-turn\": ({ width }) => {\n    const turn = (degrees: number) => `perspective(${px(width * 1.2)}) rotateY(${degrees}deg)`;\n    return {\n      oldOnTop: true,\n      old: [\n        { transformOrigin: \"left center\", transform: turn(0), opacity: 1 },\n        { transformOrigin: \"left center\", transform: turn(68), opacity: 1, offset: 0.75 },\n        { transformOrigin: \"left center\", transform: turn(90), opacity: 0 },\n      ],\n    };\n  },\n\n  fade: () => ({ new: [{ opacity: 0 }, { opacity: 1 }] }),\n\n  \"wipe-right\": () => ({ new: [{ clipPath: \"inset(0 100% 0 0)\" }, { clipPath: \"inset(0)\" }] }),\n\n  \"wipe-down\": () => ({ new: [{ clipPath: \"inset(0 0 100% 0)\" }, { clipPath: \"inset(0)\" }] }),\n\n  // A mask 2.25 screens wide, opaque on its left 44% and clear on its right 44%, slides from right to left.\n  // The soft band between is a quarter of the screen, and it crosses the whole screen.\n  feather: () => {\n    const mask = {\n      maskImage: \"linear-gradient(to right, black 44.4%, transparent 55.6%)\",\n      maskSize: \"225% 100%\",\n      maskRepeat: \"no-repeat\",\n    };\n    return {\n      new: [\n        { ...mask, maskPosition: \"100% 0\" },\n        { ...mask, maskPosition: \"0% 0\" },\n      ],\n    };\n  },\n\n  curtain: () => ({ new: [{ clipPath: \"inset(0 50%)\" }, { clipPath: \"inset(0)\" }] }),\n\n  diagonal: ({ width, height }) => {\n    const far = 2 * Math.max(width, height);\n    return {\n      new: [\n        {\n          clipPath: polygon([\n            { x: 0, y: 0 },\n            { x: 0, y: 0 },\n            { x: 0, y: 0 },\n          ]),\n        },\n        {\n          clipPath: polygon([\n            { x: 0, y: 0 },\n            { x: far, y: 0 },\n            { x: 0, y: far },\n          ]),\n        },\n      ],\n    };\n  },\n};\n\nexport type RevealOption = ThemeReveal | ThemeReveal[] | \"random\" | \"none\";\n\nlet lastRandom: ThemeReveal | undefined;\n\n/** One reveal from a name, \"random\" (any reveal) or a list to pick from; never the same random one twice */\nfunction pickReveal(option: RevealOption): ThemeReveal | undefined {\n  if (option === \"none\") return undefined;\n  if (typeof option === \"string\" && option !== \"random\") return option;\n  const pool: readonly ThemeReveal[] = option === \"random\" ? themeReveals : option;\n  const fresh = pool.length > 1 ? pool.filter((name) => name !== lastRandom) : pool;\n  const chosen = fresh[Math.floor(Math.random() * fresh.length)];\n  lastRandom = chosen;\n  return chosen;\n}\n\nexport type RevealThemeOptions = {\n  /** The theme to switch to, as your theme provider names it */\n  theme: string;\n  /** Applies the theme, for example `setTheme` from next-themes' useTheme() */\n  setTheme: (theme: string) => void;\n  /** Where the reveal starts, in viewport pixels; the centre of the screen if left out */\n  origin?: Point;\n  reveal?: RevealOption;\n  speed?: ThemeRevealSpeed | number;\n};\n\n/**\n * Switches the theme with a reveal. ThemeToggle uses it; call it yourself to switch from a menu item or a\n * shortcut. Returns the view transition, or nothing when the switch was instant.\n */\nfunction revealTheme({\n  theme,\n  setTheme,\n  origin,\n  reveal = \"circle\",\n  speed = \"medium\",\n}: RevealThemeOptions): ViewTransition | undefined {\n  // flushSync renders the theme provider's update straight away, so the new snapshot already has the theme\n  const apply = () => flushSync(() => setTheme(theme));\n  const name = pickReveal(reveal);\n  const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  if (!name || reduce || typeof document.startViewTransition !== \"function\") {\n    apply();\n    return undefined;\n  }\n\n  const root = document.documentElement;\n  const width = window.innerWidth;\n  const height = window.innerHeight;\n  const from = origin ?? { x: width / 2, y: height / 2 };\n  const radius = Math.hypot(Math.max(from.x, width - from.x), Math.max(from.y, height - from.y));\n  const keyframes = reveals[name]({ origin: from, width, height, radius });\n  const timing: KeyframeAnimationOptions = {\n    duration: typeof speed === \"number\" ? speed : revealSpeeds[speed],\n    easing: `cubic-bezier(${ease.inOut.join(\", \")})`,\n    fill: \"both\",\n  };\n\n  // While it runs: no default cross-fade, the old snapshot on top when it's the one that moves, and no CSS\n  // transitions, so colours don't fade a second time inside the new snapshot\n  const style = document.createElement(\"style\");\n  style.textContent = [\n    \"::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}\",\n    keyframes.oldOnTop ? \"::view-transition-old(root){z-index:1}\" : \"\",\n    \"*,*::before,*::after{transition:none!important}\",\n  ].join(\"\");\n  document.head.appendChild(style);\n\n  // The animations fill both ways so the first and last frames hold; they're cancelled once the transition\n  // ends, or their final frame would still apply to the next switch's snapshots\n  const running: Animation[] = [];\n  const transition = document.startViewTransition(apply);\n  transition.ready\n    .then(() => {\n      if (keyframes.new) {\n        running.push(\n          root.animate(keyframes.new, { ...timing, pseudoElement: \"::view-transition-new(root)\" }),\n        );\n      }\n      if (keyframes.old) {\n        running.push(\n          root.animate(keyframes.old, { ...timing, pseudoElement: \"::view-transition-old(root)\" }),\n        );\n      }\n    })\n    .catch(() => {\n      // Skipped (another transition started); the theme has still changed\n    });\n  transition.finished.finally(() => {\n    for (const animation of running) animation.cancel();\n    style.remove();\n  });\n  return transition;\n}\n\nexport type ThemeToggleProps = Omit<ButtonProps, \"children\" | \"asChild\"> & {\n  /** How the new theme is revealed: one reveal, \"random\", a list to pick from at random, or \"none\" */\n  reveal?: RevealOption;\n  /** How long the reveal takes: \"quick\" (450ms), \"medium\" (700ms), \"slow\" (1000ms) or milliseconds */\n  speed?: ThemeRevealSpeed | number;\n};\n\nconst noop = () => () => {};\n\n/** Ray angles round the sun, and the two stars that twinkle in beside the moon (x, y, radius) */\nconst rayAngles = [0, 45, 90, 135, 180, 225, 270, 315];\nconst stars = [\n  { x: 17, y: 6.5, r: 2.4 },\n  { x: 21.5, y: 3.5, r: 1.4 },\n];\n/** A four-point sparkle: each side curves in towards the centre */\nconst sparkle = ({ x, y, r }: { x: number; y: number; r: number }) =>\n  `M${x} ${y - r}Q${x} ${y} ${x + r} ${y}Q${x} ${y} ${x} ${y + r}Q${x} ${y} ${x - r} ${y}Q${x} ${y} ${x} ${y - r}Z`;\n\n/**\n * One drawing that morphs between a sun and a moon. Going to the moon, the rays shrink and spin away, the\n * sun swells on a bouncy spring to a radius of 9, and a shadow (a mask circle of radius 6.4) slides over its\n * top right to carve a full, centred crescent, as in Lucide's moon. Two stars then twinkle in: one in the\n * crescent's bite, one just outside the moon. Going back, the shadow slides off and the rays pop out one after another.\n * Only transforms and opacity move. It starts in its current state without animating (initial={false}),\n * so the server render already shows the finished drawing.\n */\nfunction SunMoon({ moon, className }: { moon: boolean; className?: string }) {\n  const reduce = useReducedMotion();\n  const maskId = `sun-moon-${useId().replace(/[^w-]/g, \"\")}`;\n  const move = (transition: Transition): Transition => (reduce ? { duration: 0 } : transition);\n  const stagger = (seconds: number) => (reduce ? 0 : seconds);\n\n  return (\n    <motion.svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      aria-hidden\n      className={className}\n      initial={false}\n      animate={moon ? \"moon\" : \"sun\"}\n      variants={{ sun: { rotate: 90 }, moon: { rotate: 0 } }}\n      transition={move(spring.smooth)}\n    >\n      <mask id={maskId}>\n        <rect width=\"24\" height=\"24\" fill=\"white\" />\n        <motion.circle\n          cx=\"12\"\n          cy=\"12\"\n          r=\"6.4\"\n          fill=\"black\"\n          variants={{ sun: { x: 14, y: -14 }, moon: { x: 4.5, y: -4.5 } }}\n          transition={move(spring.snappy)}\n        />\n      </mask>\n      <motion.circle\n        cx=\"12\"\n        cy=\"12\"\n        r=\"5\"\n        fill=\"currentColor\"\n        mask={`url(#${maskId})`}\n        variants={{ sun: { scale: 1 }, moon: { scale: 1.8 } }}\n        transition={move(spring.bouncy)}\n      />\n      <motion.g\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        variants={{\n          sun: { transition: { staggerChildren: stagger(0.035), delayChildren: stagger(0.1) } },\n          moon: { transition: { staggerChildren: 0 } },\n        }}\n      >\n        {rayAngles.map((angle) => (\n          <g key={angle} transform={`rotate(${angle} 12 12)`}>\n            <motion.line\n              x1=\"12\"\n              y1=\"1.75\"\n              x2=\"12\"\n              y2=\"3.75\"\n              variants={{ sun: { scale: 1, opacity: 1 }, moon: { scale: 0, opacity: 0 } }}\n              transition={move(spring.snappy)}\n            />\n          </g>\n        ))}\n      </motion.g>\n      <motion.g\n        fill=\"currentColor\"\n        variants={{\n          sun: { transition: { staggerChildren: 0 } },\n          moon: { transition: { staggerChildren: stagger(0.08), delayChildren: stagger(0.22) } },\n        }}\n      >\n        {stars.map((star) => (\n          <motion.path\n            key={star.x}\n            d={sparkle(star)}\n            variants={{\n              sun: { scale: 0, opacity: 0, rotate: -90 },\n              moon: { scale: 1, opacity: 1, rotate: 0 },\n            }}\n            transition={move(spring.bouncy)}\n          />\n        ))}\n      </motion.g>\n    </motion.svg>\n  );\n}\n\n/**\n * A button that switches between light and dark, revealing the new theme from where it was pressed.\n * It uses next-themes (wrap your app in its ThemeProvider) and shows the theme you'd switch to: a moon on\n * light, a sun on dark, morphing between the two. Until the theme is known in the browser, CSS picks the\n * drawing and the label is neutral.\n */\nfunction ThemeToggle({\n  reveal = \"circle\",\n  speed = \"medium\",\n  variant = \"outline\",\n  size = \"icon\",\n  onClick,\n  ...props\n}: ThemeToggleProps) {\n  const { resolvedTheme, setTheme } = useTheme();\n  const mounted = useSyncExternalStore(\n    noop,\n    () => true,\n    () => false,\n  );\n  const isDark = resolvedTheme === \"dark\";\n  const icon = \"size-(--size-icon-md)\";\n\n  return (\n    <Button\n      data-slot=\"theme-toggle\"\n      variant={variant}\n      size={size}\n      aria-label={mounted ? `Switch to ${isDark ? \"light\" : \"dark\"} theme` : \"Switch theme\"}\n      onClick={(event) => {\n        onClick?.(event);\n        if (event.defaultPrevented) return;\n        // From the pointer, or from the button's centre when it was pressed with the keyboard\n        const box = event.currentTarget.getBoundingClientRect();\n        const origin =\n          event.detail > 0\n            ? { x: event.clientX, y: event.clientY }\n            : { x: box.left + box.width / 2, y: box.top + box.height / 2 };\n        revealTheme({ theme: isDark ? \"light\" : \"dark\", setTheme, origin, reveal, speed });\n      }}\n      {...props}\n    >\n      <span className=\"grid place-items-center\">\n        {mounted ? (\n          <SunMoon moon={!isDark} className={icon} />\n        ) : (\n          <>\n            <SunMoon moon={false} className={cn(icon, \"hidden dark:block\")} />\n            <SunMoon moon className={cn(icon, \"dark:hidden\")} />\n          </>\n        )}\n      </span>\n    </Button>\n  );\n}\n\nexport { revealTheme, ThemeToggle, themeReveals };\n"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/theme-toggle"
}
