{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "button-65",
  "title": "Theme switch with a reveal",
  "description": "A sun and moon toggle that reveals the other theme from the point you pressed, as a circle, a wipe or a fade.",
  "dependencies": [],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/segments.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/button/button-65.tsx",
      "content": "'use client';\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from 'react';\n\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport {\n  PRESS_LEAD_MS,\n  pressedQuietOf,\n  usePress,\n} from '@/registry/ndeko/lib/demonstration';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\nimport { Segments, type Segment } from '@/registry/ndeko/ui/segments';\n\nexport type ThemeRevealMode = 'circle' | 'wipe' | 'fade';\n\nexport type RevealOrigin = {\n  x: number;\n  y: number;\n};\n\nconst REVEAL_MS = 520;\n\nconst REVEAL_EASING = 'cubic-bezier(0.23, 1, 0.32, 1)';\n\nconst ROOT_CLASS_FILTER = ['class'];\n\nconst VIEW_TRANSITION_STYLE = [\n  '::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; }',\n  '::view-transition-old(root) { z-index: 1; }',\n  '::view-transition-new(root) { z-index: 2; }',\n].join('\\n');\n\nfunction subscribeToRootClass(onChange: () => void) {\n  const observer = new MutationObserver(onChange);\n  observer.observe(document.documentElement, {\n    attributes: true,\n    attributeFilter: ROOT_CLASS_FILTER,\n  });\n  return () => observer.disconnect();\n}\n\nfunction readRootIsDark() {\n  return document.documentElement.classList.contains('dark');\n}\n\nfunction readRootOnServer() {\n  return false;\n}\n\nfunction useRootIsDark() {\n  return useSyncExternalStore(\n    subscribeToRootClass,\n    readRootIsDark,\n    readRootOnServer\n  );\n}\n\nconst SUN_BODY = [\n  'origin-[12px_12px] transition-transform duration-300 ease-(--ease-out-strong)',\n].join(' ');\n\nconst MOON_CUTTER =\n  'transition-[translate] duration-300 ease-(--ease-out-strong)';\n\nconst RAYS =\n  'origin-[12px_12px] transition-[scale,rotate,opacity] duration-300 ease-(--ease-out-strong)';\n\nfunction bodyStateOf(isDark: boolean) {\n  if (isDark) {\n    return 'scale-[1.8]';\n  }\n  return 'scale-100';\n}\n\nfunction cutterStateOf(isDark: boolean) {\n  if (isDark) {\n    return '-translate-x-1.5 translate-y-2';\n  }\n  return 'translate-x-0 translate-y-0';\n}\n\nfunction raysStateOf(isDark: boolean) {\n  if (isDark) {\n    return 'scale-50 rotate-90 opacity-0';\n  }\n  return 'scale-100 rotate-0 opacity-100';\n}\n\nfunction SunMoonIcon({ isDark }: { isDark: boolean }) {\n  const maskId = useId();\n\n  return (\n    <svg viewBox='0 0 24 24' aria-hidden='true' className='size-[18px]'>\n      <mask id={maskId}>\n        <rect width='24' height='24' fill='white' />\n        <circle\n          cx='24'\n          cy='-2'\n          r='8.5'\n          fill='black'\n          className={cn(MOON_CUTTER, cutterStateOf(isDark))}\n        />\n      </mask>\n      <g mask={`url(#${maskId})`}>\n        <circle\n          cx='12'\n          cy='12'\n          r='5'\n          fill='currentColor'\n          className={cn(SUN_BODY, bodyStateOf(isDark))}\n        />\n      </g>\n      <path\n        d='M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4'\n        fill='none'\n        stroke='currentColor'\n        strokeWidth='1.75'\n        strokeLinecap='round'\n        className={cn(RAYS, raysStateOf(isDark))}\n      />\n    </svg>\n  );\n}\n\nfunction farthestCornerOf(origin: RevealOrigin, width: number, height: number) {\n  const reachX = Math.max(origin.x, width - origin.x);\n  const reachY = Math.max(origin.y, height - origin.y);\n  return Math.hypot(reachX, reachY);\n}\n\nfunction circleClipsOf(origin: RevealOrigin, width: number, height: number) {\n  const radius = farthestCornerOf(origin, width, height);\n  const from = `circle(0px at ${origin.x}px ${origin.y}px)`;\n  const to = `circle(${radius}px at ${origin.x}px ${origin.y}px)`;\n  return [from, to];\n}\n\nfunction wipeClips() {\n  return ['inset(0 100% 0 0)', 'inset(0 0 0 0)'];\n}\n\nfunction clipKeyframesOf(\n  mode: ThemeRevealMode,\n  origin: RevealOrigin,\n  width: number,\n  height: number\n): Keyframe[] {\n  if (mode === 'fade') {\n    return [{ opacity: 0 }, { opacity: 1 }];\n  }\n  if (mode === 'wipe') {\n    const [from, to] = wipeClips();\n    return [{ clipPath: from }, { clipPath: to }];\n  }\n  const [from, to] = circleClipsOf(origin, width, height);\n  return [{ clipPath: from }, { clipPath: to }];\n}\n\nfunction mountViewTransitionStyle(mode: ThemeRevealMode) {\n  if (mode === 'fade') {\n    return null;\n  }\n  const style = document.createElement('style');\n  style.textContent = VIEW_TRANSITION_STYLE;\n  document.head.appendChild(style);\n  return style;\n}\n\nfunction applyDocumentTheme(isDark: boolean) {\n  document.documentElement.classList.toggle('dark', isDark);\n}\n\nfunction canTransitionDocument() {\n  return typeof document.startViewTransition === 'function';\n}\n\nfunction animateDocumentReveal(mode: ThemeRevealMode, origin: RevealOrigin) {\n  const width = window.innerWidth;\n  const height = window.innerHeight;\n  const keyframes = clipKeyframesOf(mode, origin, width, height);\n  document.documentElement.animate(keyframes, {\n    duration: REVEAL_MS,\n    easing: REVEAL_EASING,\n    pseudoElement: '::view-transition-new(root)',\n  });\n}\n\nasync function switchDocumentTheme(\n  isDark: boolean,\n  mode: ThemeRevealMode,\n  origin: RevealOrigin,\n  shouldReduceMotion: boolean\n) {\n  if (shouldReduceMotion || !canTransitionDocument()) {\n    applyDocumentTheme(isDark);\n    return;\n  }\n  const hasClipReveal = mode !== 'fade';\n  const style = mountViewTransitionStyle(mode);\n  const transition = document.startViewTransition(() => {\n    applyDocumentTheme(isDark);\n  });\n  try {\n    await transition.ready;\n  } catch {\n    style?.remove();\n    return;\n  }\n  if (hasClipReveal) {\n    animateDocumentReveal(mode, origin);\n  }\n  await transition.finished;\n  style?.remove();\n}\n\nfunction originOf(event: React.MouseEvent<HTMLButtonElement>): RevealOrigin {\n  const isFromKeyboard = event.detail === 0;\n  if (isFromKeyboard) {\n    const box = event.currentTarget.getBoundingClientRect();\n    return { x: box.left + box.width / 2, y: box.top + box.height / 2 };\n  }\n  return { x: event.clientX, y: event.clientY };\n}\n\nfunction centerOf(element: HTMLElement | null): RevealOrigin {\n  if (!element) {\n    return { x: 0, y: 0 };\n  }\n  const box = element.getBoundingClientRect();\n  return { x: box.left + box.width / 2, y: box.top + box.height / 2 };\n}\n\nfunction shownDarkOf(\n  scope: 'document' | 'local',\n  documentIsDark: boolean,\n  localIsDark: boolean\n) {\n  if (scope === 'document') {\n    return documentIsDark;\n  }\n  return localIsDark;\n}\n\nexport type ThemeSwitchProps = {\n  /** `document` switches the whole page, `local` leaves the change to the parent through `onDarkChange`. */\n  scope?: 'document' | 'local';\n  mode?: ThemeRevealMode;\n  isDark?: boolean;\n  /** Local scope only: the theme asked for, and the point the reveal starts from. */\n  onDarkChange?: (isDark: boolean, origin: RevealOrigin) => void;\n  buttonRef?: React.Ref<HTMLButtonElement>;\n  className?: string;\n};\n\nexport function ThemeSwitch({\n  scope = 'document',\n  mode = 'circle',\n  isDark = false,\n  onDarkChange,\n  buttonRef,\n  className,\n}: ThemeSwitchProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const documentIsDark = useRootIsDark();\n  const shownIsDark = shownDarkOf(scope, documentIsDark, isDark);\n  const nextIsDark = !shownIsDark;\n\n  function toggle(event: React.MouseEvent<HTMLButtonElement>) {\n    const origin = originOf(event);\n    if (scope === 'local') {\n      onDarkChange?.(nextIsDark, origin);\n      return;\n    }\n    void switchDocumentTheme(nextIsDark, mode, origin, shouldReduceMotion);\n  }\n\n  return (\n    <Button\n      ref={buttonRef}\n      variant='outline'\n      size='icon'\n      aria-label='Dark theme'\n      aria-pressed={shownIsDark}\n      onClick={toggle}\n      className={cn('text-foreground', className)}\n    >\n      <SunMoonIcon isDark={shownIsDark} />\n    </Button>\n  );\n}\n\ntype Reveal = {\n  id: number;\n  nextIsDark: boolean;\n  origin: RevealOrigin;\n  mode: ThemeRevealMode;\n};\n\nfunction darkClassOf(isDark: boolean) {\n  if (isDark) {\n    return 'dark';\n  }\n  return undefined;\n}\n\nfunction hiddenStartOf(mode: ThemeRevealMode) {\n  if (mode === 'fade') {\n    return 'opacity-0';\n  }\n  if (mode === 'wipe') {\n    return '[clip-path:inset(0_100%_0_0)]';\n  }\n  return '[clip-path:circle(0px_at_50%_50%)]';\n}\n\nconst STAGE_SURFACE = 'bg-background text-foreground';\n\ntype RevealLayerProps = {\n  reveal: Reveal;\n  onFinish: (isDark: boolean) => void;\n  children: React.ReactNode;\n};\n\n/** The next theme drawn above the current one and uncovered by a clip, so the page around it keeps its theme. */\nfunction RevealLayer({ reveal, onFinish, children }: RevealLayerProps) {\n  const layerRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const layer = layerRef.current;\n    const stage = layer?.parentElement;\n    if (!layer || !stage) {\n      return;\n    }\n    const box = stage.getBoundingClientRect();\n    const origin = {\n      x: reveal.origin.x - box.left,\n      y: reveal.origin.y - box.top,\n    };\n    const keyframes = clipKeyframesOf(\n      reveal.mode,\n      origin,\n      box.width,\n      box.height\n    );\n    const animation = layer.animate(keyframes, {\n      duration: REVEAL_MS,\n      easing: REVEAL_EASING,\n      fill: 'forwards',\n    });\n    animation.onfinish = () => onFinish(reveal.nextIsDark);\n    return () => animation.cancel();\n  }, [reveal, onFinish]);\n\n  return (\n    <div\n      ref={layerRef}\n      aria-hidden='true'\n      className={cn(\n        'absolute inset-0',\n        STAGE_SURFACE,\n        darkClassOf(reveal.nextIsDark),\n        hiddenStartOf(reveal.mode)\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\ntype LocalThemeStageProps = {\n  isDark: boolean;\n  reveal: Reveal | null;\n  onRevealEnd: (isDark: boolean) => void;\n  children: React.ReactNode;\n  className?: string;\n};\n\nexport function LocalThemeStage({\n  isDark,\n  reveal,\n  onRevealEnd,\n  children,\n  className,\n}: LocalThemeStageProps) {\n  return (\n    <div\n      inert\n      aria-hidden='true'\n      className={cn(\n        'hairline-ring ring-foreground/10 relative overflow-hidden rounded-xl',\n        STAGE_SURFACE,\n        darkClassOf(isDark),\n        className\n      )}\n    >\n      {children}\n      {reveal && (\n        <RevealLayer key={reveal.id} reveal={reveal} onFinish={onRevealEnd}>\n          {children}\n        </RevealLayer>\n      )}\n    </div>\n  );\n}\n\nconst BAR_HEIGHTS = ['h-6', 'h-9', 'h-5', 'h-11', 'h-8', 'h-12', 'h-10'];\n\nfunction MiniPage() {\n  return (\n    <div className='flex h-44 flex-col gap-3 p-4'>\n      <div className='flex items-center justify-between'>\n        <div className='flex flex-col'>\n          <span className='text-sm font-medium'>This week</span>\n          <span className='text-muted-foreground text-xs'>\n            Illustrative figures\n          </span>\n        </div>\n        <span className='bg-primary text-primary-foreground flex h-7 items-center rounded-full px-3 text-xs font-medium'>\n          Export\n        </span>\n      </div>\n      <div className='flex items-end justify-between gap-4'>\n        <span className='text-2xl font-semibold tabular-nums'>2,480</span>\n        <div className='flex h-12 items-end gap-1.5'>\n          {BAR_HEIGHTS.map((height, index) => (\n            <span\n              key={`${index}-${height}`}\n              className={cn('bg-primary/70 w-2 rounded-sm', height)}\n            />\n          ))}\n        </div>\n      </div>\n      <div className='flex gap-2'>\n        <span className='bg-foreground/[0.06] h-3 w-1/3 rounded-full' />\n        <span className='bg-foreground/[0.06] h-3 w-1/4 rounded-full' />\n      </div>\n    </div>\n  );\n}\n\nconst MODES: Segment<ThemeRevealMode>[] = [\n  { value: 'circle', label: 'Circle' },\n  { value: 'wipe', label: 'Wipe' },\n  { value: 'fade', label: 'Fade' },\n];\n\nexport default function Button65() {\n  const [mode, setMode] = useState<ThemeRevealMode>('circle');\n  const [isDark, setIsDark] = useState(false);\n  const [reveal, setReveal] = useState<Reveal | null>(null);\n  const [announcement, setAnnouncement] = useState('');\n  const revealCount = useRef(0);\n  const switchRef = useRef<HTMLButtonElement>(null);\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const { pressed, press } = usePress<'switch'>();\n  const shownIsDark = reveal?.nextIsDark ?? isDark;\n\n  const finishReveal = useCallback((nextIsDark: boolean) => {\n    setIsDark(nextIsDark);\n    setReveal(null);\n  }, []);\n\n  function changeTheme(nextIsDark: boolean, origin: RevealOrigin) {\n    if (shouldReduceMotion) {\n      setIsDark(nextIsDark);\n      return;\n    }\n    revealCount.current += 1;\n    setReveal({ id: revealCount.current, nextIsDark, origin, mode });\n  }\n\n  function changeFromPerson(nextIsDark: boolean, origin: RevealOrigin) {\n    changeTheme(nextIsDark, origin);\n    if (nextIsDark) {\n      setAnnouncement('Dark theme on');\n      return;\n    }\n    setAnnouncement('Dark theme off');\n  }\n\n  function toggleFromDemo() {\n    const origin = centerOf(switchRef.current);\n    changeTheme(!shownIsDark, origin);\n  }\n\n  function startOver() {\n    setMode('circle');\n    setIsDark(false);\n    setReveal(null);\n  }\n\n  const steps: AutoplayStep[] = [\n    { after: 2200, run: () => press('switch') },\n    { after: PRESS_LEAD_MS, run: toggleFromDemo },\n    { after: 3000, run: () => press('switch') },\n    { after: PRESS_LEAD_MS, run: toggleFromDemo },\n    { after: 1600, run: () => setMode('wipe') },\n    { after: 900, run: () => press('switch') },\n    { after: PRESS_LEAD_MS, run: toggleFromDemo },\n    { after: 2800, run: () => press('switch') },\n    { after: PRESS_LEAD_MS, run: toggleFromDemo },\n    { after: 1600, run: () => setMode('fade') },\n    { after: 900, run: () => press('switch') },\n    { after: PRESS_LEAD_MS, run: toggleFromDemo },\n    { after: 2600, run: () => press('switch') },\n    { after: PRESS_LEAD_MS, run: toggleFromDemo },\n  ];\n  const { ref } = useAutoplay(steps, {\n    loopAfter: 2200,\n    onReset: startOver,\n  });\n\n  return (\n    <div ref={ref} className='flex w-full max-w-sm flex-col gap-3'>\n      <div className='flex items-center justify-between gap-3'>\n        <Segments\n          label='Reveal'\n          value={mode}\n          segments={MODES}\n          onValueChange={setMode}\n        />\n        <ThemeSwitch\n          scope='local'\n          mode={mode}\n          isDark={shownIsDark}\n          onDarkChange={changeFromPerson}\n          buttonRef={switchRef}\n          className={pressedQuietOf(pressed === 'switch')}\n        />\n      </div>\n      <LocalThemeStage\n        isDark={isDark}\n        reveal={reveal}\n        onRevealEnd={finishReveal}\n      >\n        <MiniPage />\n      </LocalThemeStage>\n      <LiveRegion message={announcement} />\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "components",
    "button"
  ],
  "type": "registry:component"
}