{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "PixelSwap-TS-TW",
	"title": "PixelSwap",
	"description": "Pixel fragments assemble into a full cover, swap arbitrary content, then dissolve away with reversible colors and triggers.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "PixelSwap/PixelSwap.tsx",
			"content": "import { CSSProperties, KeyboardEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nexport type PixelSwapPattern =\n  | 'random'\n  | 'center'\n  | 'edges'\n  | 'left-to-right'\n  | 'right-to-left'\n  | 'top-to-bottom'\n  | 'bottom-to-top'\n  | 'diagonal'\n  | 'spiral';\n\nexport type PixelSwapTrigger = 'hover' | 'click' | 'manual';\n\nexport interface PixelSwapProps {\n  firstContent: ReactNode;\n  secondContent: ReactNode;\n  pixelSize?: number;\n  gap?: number;\n  pixelRadius?: number;\n  pixelSpin?: number;\n  pixelScale?: number;\n  fade?: boolean;\n  duration?: number;\n  pixelDuration?: number;\n  pattern?: PixelSwapPattern;\n  randomness?: number;\n  easing?: string;\n  trigger?: PixelSwapTrigger;\n  initialActive?: boolean;\n  active?: boolean;\n  onActiveChange?: (active: boolean) => void;\n  onComplete?: (active: boolean) => void;\n  aspectRatio?: string;\n  className?: string;\n  style?: CSSProperties;\n}\n\ninterface Pixel {\n  id: number;\n  left: number;\n  top: number;\n  offset: number;\n}\n\ninterface Grid {\n  pixels: Pixel[];\n  size: number;\n  gap: number;\n  width: number;\n  height: number;\n}\n\ninterface Transition {\n  to: boolean;\n  grid: Grid;\n}\n// Every pixel is a window onto its own copy of the incoming content, so the\n// grid stays bounded no matter how small the requested pixel size is.\nconst MAX_PIXELS = 220;\nconst KEYFRAME_STEPS = 14;\n\nconst PATTERNS: Record<PixelSwapPattern, (x: number, y: number) => number | null> = {\n  random: () => null,\n  center: (x, y) => Math.hypot(x - 0.5, y - 0.5) / Math.SQRT1_2,\n  edges: (x, y) => Math.min(x, 1 - x, y, 1 - y) * 2,\n  'left-to-right': x => x,\n  'right-to-left': x => 1 - x,\n  'top-to-bottom': (_x, y) => y,\n  'bottom-to-top': (_x, y) => 1 - y,\n  diagonal: (x, y) => (x + y) / 2,\n  spiral: (x, y) => {\n    const angle = (Math.atan2(y - 0.5, x - 0.5) + Math.PI) / (Math.PI * 2);\n    const radius = Math.hypot(x - 0.5, y - 0.5) / Math.SQRT1_2;\n    return (angle + radius) % 1;\n  }\n};\n\nconst EASINGS: Record<string, number[]> = {\n  linear: [0, 0, 1, 1],\n  ease: [0.25, 0.1, 0.25, 1],\n  'ease-in': [0.42, 0, 1, 1],\n  'ease-out': [0, 0, 0.58, 1],\n  'ease-in-out': [0.42, 0, 0.58, 1]\n};\n\nconst clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);\n\nconst noise = (seed: number): number => {\n  const value = Math.sin(seed * 127.1 + 311.7) * 43758.5453;\n  return value - Math.floor(value);\n};\n\nconst makeEasing = (value: string): ((progress: number) => number) => {\n  const match = /cubic-bezier\\(([^)]+)\\)/.exec(value);\n  const points = match ? match[1].split(',').map(Number) : EASINGS[value];\n  if (!points || points.length !== 4 || points.some(Number.isNaN)) return makeEasing('ease');\n\n  const [x1, y1, x2, y2] = points;\n  if (x1 === y1 && x2 === y2) return (progress: number) => progress;\n\n  const cx = 3 * x1;\n  const bx = 3 * (x2 - x1) - cx;\n  const ax = 1 - cx - bx;\n  const cy = 3 * y1;\n  const by = 3 * (y2 - y1) - cy;\n  const ay = 1 - cy - by;\n\n  return (progress: number) => {\n    let t = progress;\n    for (let i = 0; i < 5; i += 1) {\n      const slope = (3 * ax * t + 2 * bx) * t + cx;\n      if (!slope) break;\n      t -= (((ax * t + bx) * t + cx) * t - progress) / slope;\n    }\n    t = clamp(t, 0, 1);\n    return ((ay * t + by) * t + cy) * t;\n  };\n};\n\n// Pixels grow slightly past their own box so gaps and rounded corners close\n// completely by the end. Overlap is invisible because every pixel shows the\n// same content locked to the same origin.\nconst coverScale = (size: number, gap: number, radius: number): number => {\n  const p = clamp(radius, 0, 50) / 100;\n  const corner = Math.SQRT1_2 / (Math.SQRT2 * (0.5 - p) + p);\n  return ((size + gap) / size) * Math.max(1, corner);\n};\n\nconst buildGrid = ({\n  width,\n  height,\n  pixelSize,\n  gap,\n  pattern,\n  randomness\n}: {\n  width: number;\n  height: number;\n  pixelSize: number;\n  gap: number;\n  pattern: PixelSwapPattern;\n  randomness: number;\n}): Grid => {\n  let size = pixelSize;\n  let columns = Math.max(1, Math.ceil((width + gap) / (size + gap)));\n  let rows = Math.max(1, Math.ceil((height + gap) / (size + gap)));\n\n  if (columns * rows > MAX_PIXELS) {\n    size = Math.ceil(size * Math.sqrt((columns * rows) / MAX_PIXELS));\n    columns = Math.max(1, Math.ceil((width + gap) / (size + gap)));\n    rows = Math.max(1, Math.ceil((height + gap) / (size + gap)));\n  }\n\n  // Overhang the box so edge pixels stay square instead of being cut short.\n  const stride = size + gap;\n  const originX = (width - (columns * stride - gap)) / 2;\n  const originY = (height - (rows * stride - gap)) / 2;\n  const order = PATTERNS[pattern] ?? PATTERNS.random;\n  const mix = clamp(randomness, 0, 1);\n  const pixels: Pixel[] = [];\n\n  for (let row = 0; row < rows; row += 1) {\n    for (let column = 0; column < columns; column += 1) {\n      const index = row * columns + column;\n      const x = columns <= 1 ? 0.5 : column / (columns - 1);\n      const y = rows <= 1 ? 0.5 : row / (rows - 1);\n      const base = order(x, y);\n      const random = noise(index + 1);\n\n      pixels.push({\n        id: index,\n        left: originX + column * stride,\n        top: originY + row * stride,\n        offset: base === null ? random : base * (1 - mix) + random * mix\n      });\n    }\n  }\n\n  return { pixels, size, gap, width, height };\n};\n\n// One shared pair of keyframe lists for the whole grid: the window transform\n// and its exact inverse, so revealed content never drifts or scales.\nconst buildKeyframes = ({\n  ease,\n  startScale,\n  endScale,\n  spin,\n  fade\n}: {\n  ease: (progress: number) => number;\n  startScale: number;\n  endScale: number;\n  spin: number;\n  fade: boolean;\n}) => {\n  const window: Keyframe[] = [];\n  const content: Keyframe[] = [];\n\n  for (let step = 0; step <= KEYFRAME_STEPS; step += 1) {\n    const progress = step / KEYFRAME_STEPS;\n    const eased = ease(progress);\n    const scale = startScale + (endScale - startScale) * eased;\n    const angle = spin * (1 - eased);\n\n    window.push({\n      offset: progress,\n      opacity: fade ? Math.min(1, eased * 1.6) : 1,\n      transform: `rotate(${angle}deg) scale(${scale})`\n    });\n    content.push({\n      offset: progress,\n      transform: `scale(${1 / scale}) rotate(${-angle}deg)`\n    });\n  }\n\n  return { window, content };\n};\n\nfunction PixelSwap({\n  firstContent,\n  secondContent,\n  pixelSize = 64,\n  gap = 0,\n  pixelRadius = 0,\n  pixelSpin = 0,\n  pixelScale = 0.35,\n  fade = true,\n  duration = 1400,\n  pixelDuration = 450,\n  pattern = 'random',\n  randomness = 0,\n  easing = 'cubic-bezier(0.22, 1, 0.36, 1)',\n  trigger = 'hover',\n  initialActive = false,\n  active,\n  onActiveChange,\n  onComplete,\n  aspectRatio = '16 / 10',\n  className = '',\n  style\n}: PixelSwapProps) {\n  const [internalActive, setInternalActive] = useState(initialActive);\n  const [shownActive, setShownActive] = useState(active ?? initialActive);\n  const [transition, setTransition] = useState<Transition | null>(null);\n  const [box, setBox] = useState({ width: 0, height: 0 });\n\n  const containerRef = useRef<HTMLDivElement | null>(null);\n  const layerRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const pixelRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const animationsRef = useRef<Animation[]>([]);\n  const timerRef = useRef(0);\n\n  const desiredActive = active ?? internalActive;\n  const incomingIndex = transition?.to ? 1 : 0;\n\n  const grid = useMemo(\n    () =>\n      buildGrid({\n        width: box.width,\n        height: box.height,\n        pixelSize: Math.max(8, Math.round(pixelSize)),\n        gap: Math.max(0, Math.round(gap)),\n        pattern,\n        randomness\n      }),\n    [box.width, box.height, pixelSize, gap, pattern, randomness]\n  );\n\n  // Snapshot the animation inputs so a transition already in flight is never\n  // rebuilt halfway through by an unrelated prop change.\n  const config = { duration, pixelDuration, pixelSpin, pixelScale, pixelRadius, fade, easing, onComplete };\n  const configRef = useRef(config);\n  const gridRef = useRef(grid);\n  configRef.current = config;\n  gridRef.current = grid;\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    // Measure the padding box, which is the coordinate space the absolutely\n    // positioned layers and pixel grid actually live in.\n    const measure = () => {\n      const width = container.clientWidth;\n      const height = container.clientHeight;\n      if (!width || !height) return;\n      setBox(current => (current.width === width && current.height === height ? current : { width, height }));\n    };\n\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(container);\n    return () => observer.disconnect();\n  }, []);\n\n  const stopAnimations = useCallback(() => {\n    animationsRef.current.forEach(animation => animation.cancel());\n    animationsRef.current = [];\n    pixelRefs.current.forEach(pixel => pixel?.replaceChildren());\n    if (timerRef.current) window.clearTimeout(timerRef.current);\n    timerRef.current = 0;\n  }, []);\n\n  useEffect(() => stopAnimations, [stopAnimations]);\n\n  useEffect(() => {\n    if (transition || desiredActive === shownActive) return;\n    setTransition({ to: desiredActive, grid: gridRef.current });\n  }, [desiredActive, shownActive, transition]);\n\n  useEffect(() => {\n    if (!transition) return;\n    const settings = configRef.current;\n    const { grid: frozenGrid, to } = transition;\n\n    const finish = () => {\n      stopAnimations();\n      setShownActive(to);\n      setTransition(null);\n      settings.onComplete?.(to);\n    };\n\n    const source = layerRefs.current[to ? 1 : 0];\n    if (!source || !frozenGrid.pixels.length || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n      finish();\n      return;\n    }\n\n    const total = Math.max(200, settings.duration);\n    const pixelMs = clamp(settings.pixelDuration, 60, total);\n    const spread = Math.max(0, total - pixelMs);\n    const endScale = coverScale(frozenGrid.size, frozenGrid.gap, settings.pixelRadius);\n    const keyframes = buildKeyframes({\n      ease: makeEasing(settings.easing),\n      startScale: clamp(settings.pixelScale, 0.05, 1) * endScale,\n      endScale,\n      spin: settings.pixelSpin,\n      fade: settings.fade\n    });\n\n    frozenGrid.pixels.forEach((pixel, index) => {\n      const pixelElement = pixelRefs.current[index];\n      if (!pixelElement) return;\n\n      // Clone the rendered layer instead of re-rendering the content through\n      // React once per pixel: same visual result, a fraction of the cost.\n      const content = document.createElement('div');\n      content.className = 'absolute';\n      content.style.left = `${-pixel.left}px`;\n      content.style.top = `${-pixel.top}px`;\n      content.style.width = `${frozenGrid.width}px`;\n      content.style.height = `${frozenGrid.height}px`;\n      // Counter-transform about the pixel's centre, not the content's, so the\n      // two transforms cancel to an exact identity at every frame.\n      const originX = pixel.left + frozenGrid.size / 2;\n      const originY = pixel.top + frozenGrid.size / 2;\n      content.style.transformOrigin = `${originX}px ${originY}px`;\n\n      const clone = source.cloneNode(true) as HTMLElement;\n      clone.classList.remove('invisible');\n      clone.dataset.visible = 'true';\n      clone.removeAttribute('aria-hidden');\n      content.appendChild(clone);\n      pixelElement.replaceChildren(content);\n\n      const timing: KeyframeAnimationOptions = {\n        duration: pixelMs,\n        delay: pixel.offset * spread,\n        easing: 'linear',\n        fill: 'both'\n      };\n      animationsRef.current.push(\n        pixelElement.animate(keyframes.window, timing),\n        content.animate(keyframes.content, timing)\n      );\n    });\n\n    timerRef.current = window.setTimeout(finish, total);\n    return stopAnimations;\n  }, [stopAnimations, transition]);\n\n  const requestActive = useCallback(\n    (next: boolean) => {\n      if (active === undefined) setInternalActive(next);\n      onActiveChange?.(next);\n    },\n    [active, onActiveChange]\n  );\n\n  const interactionProps = useMemo(() => {\n    if (trigger === 'hover') {\n      return {\n        onMouseEnter: () => requestActive(true),\n        onMouseLeave: () => requestActive(false),\n        onFocus: () => requestActive(true),\n        onBlur: () => requestActive(false),\n        tabIndex: 0\n      };\n    }\n\n    if (trigger === 'click') {\n      return {\n        onClick: () => requestActive(!desiredActive),\n        onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {\n          if (event.key === 'Enter' || event.key === ' ') {\n            event.preventDefault();\n            requestActive(!desiredActive);\n          }\n        },\n        role: 'button',\n        tabIndex: 0\n      };\n    }\n\n    return {};\n  }, [desiredActive, requestActive, trigger]);\n\n  const renderLayer = (content: ReactNode, index: number) => {\n    const isShown = index === (shownActive ? 1 : 0);\n    return (\n      <div\n        key={index}\n        ref={element => {\n          layerRefs.current[index] = element;\n        }}\n        className=\"absolute inset-0 h-full w-full data-[visible=false]:invisible\"\n        data-visible={isShown && !(transition && index === incomingIndex)}\n        style={{ zIndex: isShown ? 2 : 1 }}\n        aria-hidden={!isShown}\n      >\n        {content}\n      </div>\n    );\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      className={`relative isolate w-full overflow-hidden outline-none ${className}`.trim()}\n      style={{ aspectRatio, ...style }}\n      data-active={shownActive}\n      data-transitioning={!!transition}\n      {...interactionProps}\n    >\n      {renderLayer(firstContent, 0)}\n      {renderLayer(secondContent, 1)}\n\n      {transition && (\n        <div className=\"pointer-events-none absolute inset-0 z-[3]\" aria-hidden=\"true\">\n          {transition.grid.pixels.map((pixel, index) => (\n            <div\n              key={pixel.id}\n              ref={element => {\n                pixelRefs.current[index] = element;\n              }}\n              className=\"absolute overflow-hidden opacity-0 [contain:paint]\"\n              style={{\n                left: pixel.left,\n                top: pixel.top,\n                width: transition.grid.size,\n                height: transition.grid.size,\n                borderRadius: `${clamp(pixelRadius, 0, 50)}%`\n              }}\n            />\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default PixelSwap;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}