{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "theme-toggle-reveal",
  "type": "registry:ui",
  "title": "Theme Toggle Reveal",
  "description": "A pill toggle labelled Light mode or Dark mode. Click it and the new theme is drawn in pixels out from the toggle, in one of eight reveals: circle, diamond, equalizer bars, a diagonal sweep, a zipper closing from top and bottom, sparks, sonar rings or a spiral. View Transitions API with the cross-fade switched off; the sun pulls in its rays and the label turns like a drum.",
  "dependencies": [
    "gsap",
    "@gsap/react",
    "lucide-react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/theme-toggle-reveal.tsx",
      "type": "registry:component",
      "target": "components/tweenui/theme-toggle-reveal.tsx",
      "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState, type ComponentPropsWithRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { Moon, Sun } from 'lucide-react';\nimport { flushSync } from 'react-dom';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP);\n\nexport type Theme = 'light' | 'dark';\n\nconst DURATION = 900;\nconst PIXEL = 12;\nconst FRINGE = 1.5;\nconst CLIP_RULE = 'html[data-theme-reveal]::view-transition-new(root)';\nconst MORPH = 0.55;\nconst LABELS: Record<Theme, string> = { light: 'Light mode', dark: 'Dark mode' };\n\n// The default view transition cross-fades the old and new snapshots, so while\n// the circle grows the old page is already fading out underneath it and the\n// pixels look washed out. Both snapshots are held fully opaque, with no blending,\n// and only the clip-path on the new one moves. The rule is scoped to an\n// attribute that exists for the length of this transition only, so other view\n// transitions on the page keep their own animations.\n//\n// While the snapshots are on screen the pointer is over the transition layer,\n// not the button, so the button's own not-allowed cursor never shows. The\n// cursor is set on the layer and the root as well, so it reads as disabled for\n// exactly as long as a click can't switch the theme.\nconst REVEAL_CSS = `\nhtml[data-theme-reveal]::view-transition-old(root),\nhtml[data-theme-reveal]::view-transition-new(root) {\n  animation: none;\n  mix-blend-mode: normal;\n}\nhtml[data-theme-reveal],\nhtml[data-theme-reveal]::view-transition {\n  cursor: not-allowed;\n}`;\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst easeInOut = (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);\n\nexport type Reveal =\n  'circle' | 'diamond' | 'equalizer' | 'sweep' | 'zipper' | 'sparks' | 'sonar' | 'spiral';\n\nconst SPARK_SHARE = 0.14;\nconst SONAR_RINGS = [0.07, 0.15];\nconst SPIRAL_TURNS = 2.5;\nconst BAR = 3;\nconst BAR_STAGGER = 0.35;\nconst TOOTH = 0.08;\n\n// Every reveal is the same grid of pixels; only the moment each cell lights\n// differs. That moment, the cell's reach, runs 0 to 1 and is worked out once\n// per click. Frames then light every cell whose reach the progress has passed,\n// one rectangle per run of lit cells in a row so a full-screen frame stays\n// small, and grow the cells just ahead of it from a dot to a full tile, a\n// cell and a half deep, which softens the pop of each new step of pixels.\n//\n// circle     the distance from the icon, so the edge is a staircase circle.\n// diamond    the same, counted in rows plus columns, for a stepped 45° edge.\n// equalizer  bars three pixels wide rise from the bottom like a level meter,\n//            the bars nearest the toggle first, so the wave spreads sideways.\n// sweep      the distance along the line from the toggle to the farthest\n//            corner, so a stepped diagonal crosses the screen.\n// zipper     rows close in from the top and bottom towards the toggle's row,\n//            with alternate columns a step ahead, so the two edges interlock\n//            like the teeth of a zip.\n// sparks     the circle, except one cell in seven reaches far ahead early, so\n//            loose pixels fly out and the circle fills in behind them.\n// sonar      the circle, with two thin rings of pixels racing ahead of it\n//            that come and go like pings.\n// spiral     mostly distance, plus the angle around the icon wound into an\n//            arm, so the new theme sweeps round as it grows.\nconst pixelReveal = (reveal: Reveal, x: number, y: number, pixel: number) => {\n  const width = window.innerWidth;\n  const height = window.innerHeight;\n  const cols = Math.ceil(width / pixel);\n  const rows = Math.ceil(height / pixel);\n  // Every reveal has to end exactly on the last cell on screen. For the circle\n  // that is the corner farthest from the icon: a fixed 150% looked fine from\n  // the middle of the screen, but from a toggle near an edge the far corner is\n  // a whole diagonal away, so the circle stopped short and that corner jumped\n  // to the new theme in the final frame.\n  const reachX = Math.max(x, width - x) + pixel;\n  const reachY = Math.max(y, height - y) + pixel;\n  const far = Math.hypot(reachX, reachY);\n  const farSteps = reachX + reachY;\n  const cornerX = x < width / 2 ? width : 0;\n  const cornerY = y < height / 2 ? height : 0;\n  const toCorner = Math.hypot(cornerX - x, cornerY - y) || 1;\n  const ux = (cornerX - x) / toCorner;\n  const uy = (cornerY - y) / toCorner;\n  const along = [0, width].flatMap((cx) => [0, height].map((cy) => (cx - x) * ux + (cy - y) * uy));\n  const alongMin = Math.min(...along) - pixel;\n  const alongSpan = Math.max(...along) + pixel - alongMin;\n\n  const fringeBy: Record<Reveal, number> = {\n    circle: (FRINGE * pixel) / far,\n    sparks: (FRINGE * pixel) / far,\n    sonar: (FRINGE * pixel) / far,\n    spiral: (FRINGE * pixel) / far,\n    diamond: (FRINGE * pixel) / farSteps,\n    equalizer: ((1 - BAR_STAGGER) * FRINGE) / rows,\n    sweep: (FRINGE * pixel) / alongSpan,\n    zipper: ((1 - TOOTH) * FRINGE * pixel) / reachY,\n  };\n  const fringe = fringeBy[reveal];\n  // A tile is a pixel wider than its cell so neighbours overlap and no\n  // hairline of the old theme shows between them.\n  const full = pixel + 1;\n\n  const ring = pixel / far;\n  const distance = new Float32Array(rows * cols);\n  const reach = new Float32Array(rows * cols);\n  for (let index = 0; index < reach.length; index++) {\n    const col = index % cols;\n    const row = Math.floor(index / cols);\n    const dx = (col + 0.5) * pixel - x;\n    const dy = (row + 0.5) * pixel - y;\n    distance[index] = Math.hypot(dx, dy) / far;\n    if (reveal === 'diamond') {\n      reach[index] = (Math.abs(dx) + Math.abs(dy)) / farSteps;\n    } else if (reveal === 'equalizer') {\n      const bar = Math.floor(col / BAR);\n      const barX = (bar + 0.5) * BAR * pixel;\n      const rise = (rows - row - 0.5) / rows;\n      reach[index] = BAR_STAGGER * (Math.abs(barX - x) / reachX) + (1 - BAR_STAGGER) * rise;\n    } else if (reveal === 'sweep') {\n      reach[index] = (dx * ux + dy * uy - alongMin) / alongSpan;\n    } else if (reveal === 'zipper') {\n      const ahead = (col % 2 === 0) === dy < 0;\n      reach[index] = (1 - Math.abs(dy) / reachY) * (1 - TOOTH) + (ahead ? 0 : TOOTH);\n    } else if (reveal === 'spiral') {\n      const d = distance[index];\n      const turn = Math.atan2(dy, dx) / (2 * Math.PI) + SPIRAL_TURNS * d;\n      reach[index] = 0.7 * d + 0.3 * (turn - Math.floor(turn));\n    } else {\n      const d = distance[index];\n      const spark = reveal === 'sparks' && Math.random() < SPARK_SHARE;\n      reach[index] = spark ? d * (0.25 + 0.5 * Math.random()) : d;\n    }\n  }\n\n  const square = (cx: number, cy: number, grown: number) => {\n    const size = +(grown * full).toFixed(2);\n    return `M${+(cx - size / 2).toFixed(2)} ${+(cy - size / 2).toFixed(2)}h${size}v${size}h${-size}Z`;\n  };\n\n  return (time: number) => {\n    if (time >= 1) return 'inset(0)';\n    const progress = easeInOut(time) * (1 + fringe);\n    let d = 'M0 0Z';\n    for (let row = 0; row < rows; row++) {\n      const cy = (row + 0.5) * pixel;\n      let start = -1;\n      for (let col = 0; col <= cols; col++) {\n        const index = row * cols + col;\n        const lit = col < cols && reach[index] <= progress;\n        if (lit) {\n          if (start < 0) start = col;\n          continue;\n        }\n        if (start >= 0) {\n          const run = (col - start) * pixel;\n          d += `M${start * pixel} ${row * pixel}h${run}v${pixel}h${-run}Z`;\n          start = -1;\n        }\n        if (col === cols) break;\n        let grown = 1 - (reach[index] - progress) / fringe;\n        if (reveal === 'sonar') {\n          SONAR_RINGS.forEach((lead, ping) => {\n            if (Math.abs(distance[index] - progress - lead) < ring) {\n              grown = Math.max(grown, 0.8 - 0.3 * ping);\n            }\n          });\n        }\n        if (grown > 0) d += square((col + 0.5) * pixel, cy, Math.min(grown, 1));\n      }\n    }\n    return `path('${d}')`;\n  };\n};\n\nconst applyToRoot = (theme: Theme) => {\n  const root = document.documentElement;\n  root.classList.toggle('dark', theme === 'dark');\n  root.style.colorScheme = theme;\n};\n\nconst Word = ({ text, row, shown }: { text: string; row: Theme; shown: boolean }) => (\n  <span data-row={row} className=\"col-start-1 row-start-1 whitespace-pre\">\n    {Array.from(text).map((char, index) => (\n      <span\n        key={index}\n        data-char\n        className=\"inline-block\"\n        style={shown ? undefined : { opacity: 0 }}\n      >\n        {char}\n      </span>\n    ))}\n  </span>\n);\n\nexport interface ThemeToggleRevealProps extends Omit<\n  ComponentPropsWithRef<'button'>,\n  'children' | 'onClick'\n> {\n  theme?: Theme;\n  defaultTheme?: Theme;\n  onThemeChange?: (theme: Theme) => void;\n  applyTheme?: (theme: Theme) => void;\n  labels?: Record<Theme, string>;\n  duration?: number;\n  pixelSize?: number;\n  reveal?: Reveal;\n}\n\nconst ThemeToggleReveal = ({\n  theme: themeProp,\n  defaultTheme,\n  onThemeChange,\n  applyTheme = applyToRoot,\n  labels = LABELS,\n  duration = DURATION,\n  pixelSize = PIXEL,\n  reveal = 'circle',\n  className,\n  type = 'button',\n  ref,\n  ...props\n}: ThemeToggleRevealProps) => {\n  const btnRef = useRef<HTMLButtonElement>(null);\n  const iconRef = useRef<HTMLSpanElement>(null);\n  const running = useRef(false);\n  const [busy, setBusy] = useState(false);\n  const morph = useRef(false);\n  const [ownTheme, setOwnTheme] = useState<Theme>(defaultTheme ?? 'light');\n  const controlled = themeProp !== undefined;\n  const theme = controlled ? themeProp : ownTheme;\n  const [firstTheme] = useState(theme);\n  const next: Theme = theme === 'dark' ? 'light' : 'dark';\n  const live = useRef(theme);\n\n  useLayoutEffect(() => {\n    live.current = theme;\n  }, [theme]);\n\n  const setRef = (node: HTMLButtonElement | null) => {\n    btnRef.current = node;\n    if (typeof ref === 'function') ref(node);\n    else if (ref) ref.current = node;\n  };\n\n  useEffect(() => {\n    if (controlled || defaultTheme) return;\n    if (document.documentElement.classList.contains('dark')) setOwnTheme('dark');\n  }, [controlled, defaultTheme]);\n\n  useGSAP(\n    () => {\n      const dark = theme === 'dark';\n      const sun = { rotate: dark ? -90 : 0, scale: dark ? 0.6 : 1, autoAlpha: dark ? 0 : 1 };\n      const rays = { scale: dark ? 0 : 1, svgOrigin: '12 12' };\n      const moon = { rotate: dark ? 0 : 90, scale: dark ? 1 : 0.6, autoAlpha: dark ? 1 : 0 };\n      // The two labels sit on a drum: dark is one row below light. Going dark\n      // it turns up, going light it turns back down, one letter after another.\n      const lightRow = { yPercent: dark ? -110 : 0, opacity: dark ? 0 : 1 };\n      const darkRow = { yPercent: dark ? 0 : 110, opacity: dark ? 1 : 0 };\n\n      const animate = morph.current && !prefersReducedMotion();\n      morph.current = false;\n      if (!animate) {\n        gsap.set('[data-sun]', sun);\n        gsap.set('[data-sun] path', rays);\n        gsap.set('[data-moon]', moon);\n        gsap.set('[data-row=light] [data-char]', lightRow);\n        gsap.set('[data-row=dark] [data-char]', darkRow);\n        return;\n      }\n\n      const letters = {\n        duration: MORPH * 0.7,\n        stagger: { each: 0.025, from: dark ? 'start' : 'end' },\n      } as const;\n      const tl = gsap.timeline({ defaults: { duration: MORPH, ease: 'power3.inOut' } });\n      tl.to(\n        '[data-sun] path',\n        { ...rays, duration: MORPH * 0.6, stagger: 0.02 },\n        dark ? 0 : MORPH * 0.4\n      )\n        .to('[data-sun]', sun, dark ? MORPH * 0.2 : 0)\n        .to('[data-moon]', moon, dark ? MORPH * 0.2 : 0)\n        .to('[data-row=light] [data-char]', { ...lightRow, ...letters }, 0)\n        .to('[data-row=dark] [data-char]', { ...darkRow, ...letters }, 0.05);\n    },\n    { scope: btnRef, dependencies: [theme] }\n  );\n\n  // The target is read when the update runs, not when the click happened. Two\n  // quick clicks can both land before the first update has run, and each has\n  // to flip from wherever the theme is by then, or both would pick the same\n  // theme and the second click would do nothing.\n  const commit = () => {\n    const target: Theme = live.current === 'dark' ? 'light' : 'dark';\n    live.current = target;\n    morph.current = true;\n    flushSync(() => {\n      if (!controlled) setOwnTheme(target);\n      onThemeChange?.(target);\n    });\n    applyTheme(target);\n  };\n\n  const toggle = () => {\n    const origin = iconRef.current ?? btnRef.current;\n    if (!origin || running.current) return;\n\n    if (typeof document.startViewTransition !== 'function' || prefersReducedMotion()) {\n      commit();\n      return;\n    }\n\n    // The new theme pours out of the sun or moon itself, not the middle of\n    // the pill, so the first pixels light right around the icon.\n    const box = origin.getBoundingClientRect();\n    const x = box.left + box.width / 2;\n    const y = box.top + box.height / 2;\n\n    // The pixel frames used to be precomputed keyframes, one every 1/60s.\n    // Keyframes that don't share a shape can't be blended, so the browser\n    // flipped between them halfway through each interval, and whenever that\n    // drifted off the display's own frames one shape was held twice: the\n    // circle jerked. Now a frame is drawn on every requestAnimationFrame from\n    // the real elapsed time, written into one rule for the new snapshot. An\n    // empty animation on the same pseudo-element holds the transition open for\n    // the full duration, since the transition ends when its animations do.\n    const frameAt = pixelReveal(reveal, x, y, pixelSize);\n    const clip = document.createElement('style');\n    const draw = (progress: number) => {\n      clip.textContent = `${CLIP_RULE} { clip-path: ${frameAt(progress)}; }`;\n    };\n    draw(0);\n    document.head.append(clip);\n\n    // While a reveal runs the toggle is switched off, and says so. Starting a\n    // second view transition mid-reveal skips the first one: its circle\n    // vanishes and the theme jumps, which looked like a glitch rather than a\n    // switch. Native disabled would drop keyboard focus off the button, so it\n    // is aria-disabled instead: it stays focused, ignores clicks and looks\n    // disabled until the circle has covered the screen.\n    running.current = true;\n    setBusy(true);\n    const root = document.documentElement;\n    root.dataset.themeReveal = '';\n    const transition = document.startViewTransition(commit);\n    let frame = 0;\n\n    transition.ready\n      .then(() => {\n        root.animate(\n          { opacity: [1, 1] },\n          { duration, pseudoElement: '::view-transition-new(root)' }\n        );\n        const start = performance.now();\n        const tick = (now: number) => {\n          const progress = Math.min(Math.max((now - start) / duration, 0), 1);\n          draw(progress);\n          if (progress < 1) frame = requestAnimationFrame(tick);\n        };\n        frame = requestAnimationFrame(tick);\n      })\n      .catch(() => {});\n\n    const done = () => {\n      cancelAnimationFrame(frame);\n      clip.remove();\n      delete root.dataset.themeReveal;\n      running.current = false;\n      setBusy(false);\n    };\n    transition.finished.then(done, done);\n  };\n\n  return (\n    <button\n      ref={setRef}\n      type={type}\n      aria-label={`${labels[theme]}, switch to ${next} theme`}\n      aria-disabled={busy || undefined}\n      data-theme-state={theme}\n      onClick={toggle}\n      className={cn(\n        'relative inline-flex h-12 cursor-pointer items-center gap-3 rounded-full border border-current/15 bg-current/[0.04] py-0 pr-5 pl-1.5 text-sm font-medium text-current transition-[background-color,border-color,scale,opacity] duration-200 ease-out outline-none select-none not-aria-disabled:hover:border-current/30 not-aria-disabled:hover:bg-current/[0.08] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current not-disabled:not-aria-disabled:active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 motion-reduce:transition-none motion-reduce:active:scale-100',\n        className\n      )}\n      {...props}\n    >\n      <style href=\"tween-theme-toggle-reveal\" precedence=\"default\">\n        {REVEAL_CSS}\n      </style>\n      <span\n        ref={iconRef}\n        aria-hidden=\"true\"\n        className=\"grid size-9 place-items-center rounded-full bg-current/10\"\n      >\n        <span data-sun className=\"col-start-1 row-start-1 grid\">\n          <Sun className=\"size-[18px]\" />\n        </span>\n        <span data-moon className=\"invisible col-start-1 row-start-1 grid\">\n          <Moon className=\"size-[18px]\" />\n        </span>\n      </span>\n      <span aria-hidden=\"true\" className=\"grid overflow-hidden leading-6\">\n        <Word text={labels.light} row=\"light\" shown={firstTheme === 'light'} />\n        <Word text={labels.dark} row=\"dark\" shown={firstTheme === 'dark'} />\n      </span>\n    </button>\n  );\n};\n\nexport default ThemeToggleReveal;\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/component/theme-toggle-reveal"
}