{
  "name": "fractal-glass",
  "type": "registry:component",
  "title": "Fractal Glass",
  "description": "WebGL glass-strip refraction effect with fractal distortion, edge blending, and pointer-reactive parallax.",
  "dependencies": [
    "three"
  ],
  "registryDependencies": [],
  "exportName": "FractalGlass",
  "exportKind": "default",
  "tier": "free",
  "version": "1.1.0",
  "changelog": [
    {
      "version": "1.1.0",
      "date": "2026-07-22",
      "summary": "Added reduced-motion notice",
      "breaking": false
    },
    {
      "version": "1.0.1",
      "date": "2026-07-15",
      "summary": "Removed a stray character left after a JSX comment (cosmetic, no functional change)",
      "breaking": false
    }
  ],
  "props": [
    {
      "name": "stripesFrequency",
      "type": "number",
      "default": 40,
      "description": "Controls how many refractive glass strips are rendered."
    },
    {
      "name": "glassStrength",
      "type": "number",
      "default": 2,
      "description": "Controls the intensity of the strip refraction."
    },
    {
      "name": "glassSmoothness",
      "type": "number",
      "default": 0.014,
      "description": "Controls how smoothly neighboring refraction samples blend."
    },
    {
      "name": "parallaxStrength",
      "type": "number",
      "default": 0.15,
      "description": "Controls pointer-reactive horizontal parallax."
    },
    {
      "name": "distortionMultiplier",
      "type": "number",
      "default": 8,
      "description": "Amplifies parallax around distorted stripe regions."
    },
    {
      "name": "edgePadding",
      "type": "number",
      "default": 0.12,
      "description": "Softens distortion near the media edges."
    }
  ],
  "remixer": {
    "enabled": true,
    "defaultOpenGroupId": "motion",
    "layout": {
      "controlsButtonClassName": "right-4! top-25!"
    },
    "panel": {
      "className": "right-0! top-[132px]! h-[calc(100%-132px)]! w-[344px]!"
    },
    "controls": [
      {
        "name": "stripesFrequency",
        "label": "Stripes",
        "type": "range",
        "group": "motion",
        "min": 10,
        "max": 100,
        "step": 1
      },
      {
        "name": "glassStrength",
        "label": "Glass Strength",
        "type": "range",
        "group": "motion",
        "min": 0,
        "max": 5,
        "step": 0.1
      },
      {
        "name": "glassSmoothness",
        "label": "Smoothness",
        "type": "range",
        "group": "motion",
        "min": 0.001,
        "max": 0.05,
        "step": 0.001
      },
      {
        "name": "parallaxStrength",
        "label": "Parallax",
        "type": "range",
        "group": "motion",
        "min": 0,
        "max": 0.5,
        "step": 0.01
      },
      {
        "name": "distortionMultiplier",
        "label": "Distortion",
        "type": "range",
        "group": "motion",
        "min": 0,
        "max": 20,
        "step": 0.1
      },
      {
        "name": "edgePadding",
        "label": "Edge Padding",
        "type": "range",
        "group": "motion",
        "min": 0,
        "max": 0.4,
        "step": 0.01
      }
    ]
  },
  "files": [
    {
      "path": "index.tsx",
      "type": "registry:component",
      "target": "src/components/effects/fractal-glass/index.tsx",
      "content": "// Built using Hyperiux Vault: https://vault.hyperiux.com\n\n'use client';\n\nimport { useEffect, useRef, useState } from \"react\";\nimport * as THREE from \"three\";\nimport { createSuspendedRaf } from \"./createSuspendedRaf\";\n\nconst DEFAULT_IMAGE_SRC = \"https://pub-8abee449136941f5b0a1cd2c014534e9.r2.dev/vault-listing-images/assets-images/h-13.jpg\";\n\nfunction usePrefersReducedMotion() {\n  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const update = () => setPrefersReducedMotion(mediaQuery.matches);\n    update();\n    mediaQuery.addEventListener(\"change\", update);\n    return () => mediaQuery.removeEventListener(\"change\", update);\n  }, []);\n\n  return prefersReducedMotion;\n}\n\nconst vertexShader = `\n varying vec2 vUv;\n void main() {\n vUv = uv;\n gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n }\n`;\n\nconst fragmentShader = `\n uniform sampler2D uTexture;\n uniform vec2 uResolution;\n uniform vec2 uTextureSize;\n uniform vec2 uMouse;\n uniform float uParallaxStrength;\n uniform float uDistortionMultiplier;\n uniform float uGlassStrength;\n uniform float uStripesFrequency;\n uniform float uGlassSmoothness;\n uniform float uEdgePadding;\n\n varying vec2 vUv;\n\n vec2 getCoverUV(vec2 uv, vec2 textureSize) {\n if (textureSize.x < 1.0 || textureSize.y < 1.0) return uv;\n\n vec2 s = uResolution / textureSize;\n float scale = max(s.x, s.y);\n\n vec2 scaledSize = textureSize * scale;\n vec2 offset = (uResolution - scaledSize) * 0.5;\n\n return (uv * uResolution - offset) / scaledSize;\n }\n\n float displacement(float x, float num_stripes, float strength) {\n float modulus = 1.0 / num_stripes;\n return mod(x, modulus) * strength;\n }\n\n float fractalGlass(float x) {\n float stripeWidth = 1.0 / uStripesFrequency;\n float sampleStep = uGlassSmoothness * stripeWidth;\n float d = 0.0;\n for (int i = -5; i <= 5; i++) {\n d += displacement(x + float(i) * sampleStep, uStripesFrequency, uGlassStrength);\n }\n d = d / 11.0;\n return x + d;\n }\n\n float smoothEdge(float x, float padding) {\n float edge = padding;\n if (x < edge) {\n return smoothstep(0.0, edge, x);\n } else if (x > 1.0 - edge) {\n return smoothstep(1.0, 1.0 - edge, x);\n }\n return 1.0;\n }\n\n void main() {\n vec2 uv = vUv;\n\n float originalX = uv.x;\n\n float edgeFactor = smoothEdge(originalX, uEdgePadding);\n\n float distortedX = fractalGlass(originalX);\n\n uv.x = mix(originalX, distortedX, edgeFactor);\n\n float distortionFactor = uv.x - originalX;\n\n float parallaxDirection = -sign(0.5 - uMouse.x);\n\n vec2 parallaxOffset = vec2(\n parallaxDirection * abs(uMouse.x - 0.5) * uParallaxStrength * (1.0 + abs(distortionFactor) * uDistortionMultiplier),\n 0.0\n );\n\n parallaxOffset *= edgeFactor;\n\n uv += parallaxOffset;\n\n vec2 coverUV = getCoverUV(uv, uTextureSize);\n\n if (coverUV.x < 0.0 || coverUV.x > 1.0 || coverUV.y < 0.0 || coverUV.y > 1.0) {\n coverUV = clamp(coverUV, 0.0, 1.0);\n }\n\n vec4 color = texture2D(uTexture, coverUV);\n\n gl_FragColor = color;\n }\n`;\n\nfunction resolveMediaSource(source: any) {\n  if (typeof source === \"string\") return source;\n  if (source?.src) return source.src;\n\n  return source;\n}\n\nfunction loadImageElement(source: any, fallbackSource = DEFAULT_IMAGE_SRC): Promise<{ image: HTMLImageElement, objectUrl: string }> {\n  return new Promise((resolve, reject) => {\n    const imageSource = resolveMediaSource(source) || fallbackSource;\n\n    if (!imageSource) {\n      reject(new Error(\"A valid image URL is required.\"));\n      return;\n    }\n\n    fetch(imageSource, {\n      mode: \"cors\",\n      credentials: \"omit\",\n      cache: \"no-store\",\n    })\n      .then((response) => {\n        if (!response.ok) {\n          throw new Error(`Image request failed with ${response.status}`);\n        }\n\n        return response.blob();\n      })\n      .then((blob) => {\n        const objectUrl = URL.createObjectURL(blob);\n        const image = new Image();\n\n        image.onload = () => resolve({ image, objectUrl });\n        image.onerror = () => {\n          URL.revokeObjectURL(objectUrl);\n          reject(new Error(`Unable to decode glass strip image: ${imageSource}`));\n        };\n        image.src = objectUrl;\n      })\n      .catch((error) => {\n        if (imageSource !== fallbackSource) {\n          loadImageElement(fallbackSource, fallbackSource).then(resolve).catch(reject);\n          return;\n        }\n\n        reject(\n          new Error(\n            `Unable to load glass strip image: ${imageSource}. Current origin is ${window.location.origin}. Make sure the URL allows CORS for this site. ${error?.message || \"\"}`\n          )\n        );\n      });\n  });\n}\n\ninterface FractalGlassProps {\n  imageSrc?: string\n  videoSrc?: any\n  mediaType?: 'image' | 'video'\n  stripesFrequency?: number\n  glassStrength?: number\n  glassSmoothness?: number\n  parallaxStrength?: number\n  distortionMultiplier?: number\n  edgePadding?: number\n}\nexport default function FractalGlass({\n  imageSrc = DEFAULT_IMAGE_SRC,\n  videoSrc = null,\n  mediaType = \"image\",\n  stripesFrequency = 40,\n  glassStrength = 2.0,\n  glassSmoothness = 0.014,\n  parallaxStrength = 0.15,\n  distortionMultiplier = 8.0,\n  edgePadding = 0.12,\n}: FractalGlassProps) {\n  const mountRef = useRef<HTMLDivElement | null>(null);\n  const videoRef = useRef<HTMLVideoElement | null>(null); // keeps reference to video element for cleanup\n  const uniformsRef = useRef<any>(null);\n  const prefersReducedMotion = usePrefersReducedMotion();\n\n  useEffect(() => {\n    const el = mountRef.current;\n    if (!el) return;\n\n    const W = el.clientWidth;\n    const H = el.clientHeight;\n\n    const renderer = new THREE.WebGLRenderer({ antialias: true });\n    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n    renderer.setSize(W, H);\n    renderer.domElement.setAttribute(\"aria-hidden\", \"true\");\n    el.appendChild(renderer.domElement);\n\n    const scene = new THREE.Scene();\n    const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);\n    camera.position.z = 1;\n\n    const uniforms = {\n      uTexture: { value: new THREE.Texture() },\n      uResolution: { value: new THREE.Vector2(W, H) },\n      uTextureSize: { value: new THREE.Vector2(1, 1) },\n      uMouse: { value: new THREE.Vector2(0.5, 0.5) },\n      uParallaxStrength: { value: parallaxStrength },\n      uDistortionMultiplier: { value: distortionMultiplier },\n      uGlassStrength: { value: glassStrength },\n      uStripesFrequency: { value: stripesFrequency },\n      uGlassSmoothness: { value: glassSmoothness },\n      uEdgePadding: { value: edgePadding },\n    };\n    uniformsRef.current = uniforms;\n    let videoEl: HTMLVideoElement | null = null;\n    let videoTexture: THREE.VideoTexture | null = null;\n    let imageTexture: THREE.Texture | null = null;\n    let imageObjectUrl: string | null = null;\n    let isDisposed = false;\n\n    if (mediaType === \"video\" && videoSrc) {\n      // ── Video path ──────────────────────────────────────────────\n      videoEl = document.createElement(\"video\");\n      const currentVideoEl = videoEl;\n      currentVideoEl.crossOrigin = \"anonymous\";\n      (currentVideoEl as any).referrerPolicy = \"no-referrer\";\n      currentVideoEl.loop = true;\n      currentVideoEl.muted = true;\n      currentVideoEl.playsInline = true;\n      currentVideoEl.autoplay = true;\n      currentVideoEl.src = resolveMediaSource(videoSrc);\n      videoRef.current = currentVideoEl;\n\n      currentVideoEl.addEventListener(\"loadedmetadata\", () => {\n        uniforms.uTextureSize.value.set(currentVideoEl.videoWidth, currentVideoEl.videoHeight);\n      });\n\n      videoEl.play().catch(() => {\n        // Autoplay blocked — still renders first frame when available\n      });\n\n      videoTexture = new THREE.VideoTexture(videoEl);\n      videoTexture.minFilter = THREE.LinearFilter;\n      videoTexture.magFilter = THREE.LinearFilter;\n      videoTexture.wrapS = THREE.ClampToEdgeWrapping;\n      videoTexture.wrapT = THREE.ClampToEdgeWrapping;\n      uniforms.uTexture.value = videoTexture;\n\n    } else {\n      // ── Image path ──────────────────────────────────────────────\n      loadImageElement(imageSrc).then(({ image, objectUrl }) => {\n        if (isDisposed) return;\n\n        imageObjectUrl = objectUrl;\n        imageTexture = new THREE.Texture(image);\n        imageTexture.needsUpdate = true;\n        imageTexture.minFilter = THREE.LinearFilter;\n        imageTexture.magFilter = THREE.LinearFilter;\n        imageTexture.wrapS = THREE.ClampToEdgeWrapping;\n        imageTexture.wrapT = THREE.ClampToEdgeWrapping;\n        uniforms.uTexture.value = imageTexture;\n        uniforms.uTextureSize.value.set(\n          image.naturalWidth || image.width || 1920,\n          image.naturalHeight || image.height || 1080\n        );\n      }).catch((error) => {\n        console.warn(\n          `Unable to load glass strip texture: ${resolveMediaSource(imageSrc)}. Make sure the public URL allows CORS for this site.`,\n          error\n        );\n      });\n    }\n\n    const geo = new THREE.PlaneGeometry(2, 2);\n    const mat = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms });\n    scene.add(new THREE.Mesh(geo, mat));\n\n    const target = { x: 0.5, y: 0.5 };\n    const current = { x: 0.5, y: 0.5 };\n\n    const setTarget = (x: number, y: number) => {\n      target.x = x / window.innerWidth;\n      target.y = 1 - y / window.innerHeight;\n    };\n    const onMouse = (e: MouseEvent) => setTarget(e.clientX, e.clientY);\n    const onTouch = (e: TouchEvent) => setTarget(e.touches[0].clientX, e.touches[0].clientY);\n    window.addEventListener(\"mousemove\", onMouse);\n    window.addEventListener(\"touchmove\", onTouch, { passive: true });\n\n    const onResize = () => {\n      const w = el.clientWidth, h = el.clientHeight;\n      renderer.setSize(w, h);\n      uniforms.uResolution.value.set(w, h);\n    };\n    window.addEventListener(\"resize\", onResize);\n\n    const loop = createSuspendedRaf({\n      root: el,\n      onFrame: () => {\n        current.x += (target.x - current.x) * 0.04;\n        current.y += (target.y - current.y) * 0.04;\n        uniforms.uMouse.value.set(current.x, current.y);\n        renderer.render(scene, camera);\n      },\n    });\n    loop.start();\n\n    return () => {\n      isDisposed = true;\n      uniformsRef.current = null;\n      loop.destroy();\n      window.removeEventListener(\"mousemove\", onMouse);\n      window.removeEventListener(\"touchmove\", onTouch);\n      window.removeEventListener(\"resize\", onResize);\n      if (videoEl) {\n        videoEl.pause();\n        videoEl.src = \"\";\n        videoRef.current = null;\n      }\n      videoTexture?.dispose();\n      imageTexture?.dispose();\n      if (imageObjectUrl) URL.revokeObjectURL(imageObjectUrl);\n      renderer.dispose();\n      mat.dispose();\n      geo.dispose();\n      if (el.contains(renderer.domElement)) el.removeChild(renderer.domElement);\n    };\n  }, [imageSrc, videoSrc, mediaType]);\n\n  useEffect(() => {\n    const uniforms = uniformsRef.current;\n    if (!uniforms) return;\n\n    uniforms.uParallaxStrength.value = parallaxStrength;\n    uniforms.uDistortionMultiplier.value = distortionMultiplier;\n    uniforms.uGlassStrength.value = glassStrength;\n    uniforms.uStripesFrequency.value = stripesFrequency;\n    uniforms.uGlassSmoothness.value = glassSmoothness;\n    uniforms.uEdgePadding.value = edgePadding;\n  }, [stripesFrequency, glassStrength, glassSmoothness, parallaxStrength, distortionMultiplier, edgePadding]);\n\n  return (\n    <div\n      ref={mountRef}\n      style={{\n        position: \"fixed\",\n        inset: 0,\n        width: \"100vw\",\n        height: \"100vh\",\n        overflow: \"hidden\",\n        background: \"#000\",\n      }}\n    >\n      {/* Mobile message */}\n      <div\n        className=\"\n        hidden\n        max-[1025px]:flex\n        fixed\n        bottom-6\n        left-1/2\n        -translate-x-1/2\n        z-50\n        px-4\n        py-2\n        rounded-full\n        bg-white/10\n        backdrop-blur-md\n        text-white\n        text-center\n        text-sm\n        leading-tight\n        pointer-events-none\n        max-md:px-[7vw] max-md:py-[4vw]\n      \"\n      >\n        Works best on desktop\n      </div>\n\n      {prefersReducedMotion && (\n        <div\n          aria-live=\"polite\"\n          className=\"pointer-events-none fixed bottom-4 right-4 z-40 w-fit max-w-65 rounded-md border border-white/15 bg-white/5 p-3 text-center backdrop-blur-sm max-md:hidden\"\n        >\n          <h2 className=\"text-sm leading-none text-white\">\n            The glass keeps shifting.\n          </h2>\n          <p className=\"mt-2 text-xs leading-5 text-white/65\">\n            Fractal Glass distorts the image based on cursor and touch\n            position in real time. Since the distortion is driven entirely\n            by motion, reduced motion can&apos;t be applied here.\n          </p>\n        </div>\n      )}\n    </div>\n  );\n}\n"
    },
    {
      "path": "createSuspendedRaf.ts",
      "type": "registry:component",
      "target": "src/components/effects/fractal-glass/createSuspendedRaf.ts",
      "content": "const DEFAULT_ROOT_MARGIN = \"256px\";\n\ntype RafRoot = Element | null | { current: Element | null } | (() => Element | null);\n\nfunction resolveElement(root: RafRoot): Element | null {\n  if (!root) return null;\n  if (typeof root === \"function\") return root() ?? null;\n  if (typeof root === \"object\" && \"current\" in root) return root.current ?? null;\n  return root;\n}\n\ninterface VisibilityGateOptions {\n  root?: RafRoot;\n  rootMargin?: string;\n  threshold?: number;\n  observeTab?: boolean;\n  observeOffscreen?: boolean;\n  onChange?: (active: boolean) => void;\n}\n\ninterface VisibilityGate {\n  readonly isActive: boolean;\n  observe: (nextRoot?: RafRoot) => void;\n  destroy: () => void;\n}\n\nfunction createVisibilityGate({\n  root = null,\n  rootMargin = DEFAULT_ROOT_MARGIN,\n  threshold = 0,\n  observeTab = true,\n  observeOffscreen = true,\n  onChange,\n}: VisibilityGateOptions = {}): VisibilityGate {\n  let tabVisible =\n    typeof document === \"undefined\" ? true : !document.hidden;\n  // Match border-beam: assume onscreen until the observer reports otherwise.\n  let onscreen = true;\n  let destroyed = false;\n  let observer: IntersectionObserver | null = null;\n\n  const isActive = () => {\n    if (destroyed) return false;\n    if (observeTab && !tabVisible) return false;\n    if (observeOffscreen && resolveElement(root) && !onscreen) return false;\n    return true;\n  };\n\n  let lastActive = isActive();\n\n  const emit = () => {\n    if (destroyed) return;\n    const next = isActive();\n    if (next === lastActive) return;\n    lastActive = next;\n    onChange?.(next);\n  };\n\n  const onVisibilityChange = () => {\n    tabVisible = !document.hidden;\n    emit();\n  };\n\n  if (observeTab && typeof document !== \"undefined\") {\n    document.addEventListener(\"visibilitychange\", onVisibilityChange);\n  }\n\n  const bindObserver = () => {\n    if (!observeOffscreen || typeof IntersectionObserver === \"undefined\") {\n      return;\n    }\n\n    const el = resolveElement(root);\n    if (!el) return;\n\n    observer = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) {\n          onscreen = entry.isIntersecting;\n        }\n        emit();\n      },\n      { rootMargin, threshold },\n    );\n\n    observer.observe(el);\n  };\n\n  bindObserver();\n\n  return {\n    /** Whether the animation should currently run. */\n    get isActive() {\n      return isActive();\n    },\n\n    /**\n     * Re-bind IntersectionObserver after the root element mounts late\n     * (e.g. ref not ready on first call). Safe to call multiple times.\n     */\n    observe(nextRoot?: RafRoot) {\n      if (destroyed) return;\n      if (nextRoot != null) root = nextRoot;\n      if (observer) {\n        observer.disconnect();\n        observer = null;\n      }\n      onscreen = true;\n      bindObserver();\n      emit();\n    },\n\n    destroy() {\n      if (destroyed) return;\n      destroyed = true;\n      if (observeTab && typeof document !== \"undefined\") {\n        document.removeEventListener(\"visibilitychange\", onVisibilityChange);\n      }\n      if (observer) {\n        observer.disconnect();\n        observer = null;\n      }\n    },\n  };\n}\n\ninterface SuspendedRafOptions {\n  onFrame: (time: number) => void;\n  root?: RafRoot;\n  rootMargin?: string;\n  threshold?: number;\n  observeTab?: boolean;\n  observeOffscreen?: boolean;\n}\n\ninterface SuspendedRaf {\n  start: () => void;\n  stop: () => void;\n  readonly isRunning: boolean;\n  readonly isActive: boolean;\n  observe: (nextRoot?: RafRoot) => void;\n  destroy: () => void;\n}\n\n/**\n * Owns a requestAnimationFrame loop that auto-pauses when the tab is hidden\n * or the root element is offscreen.\n */\nfunction createSuspendedRaf({\n  onFrame,\n  root = null,\n  rootMargin = DEFAULT_ROOT_MARGIN,\n  threshold = 0,\n  observeTab = true,\n  observeOffscreen = true,\n}: SuspendedRafOptions): SuspendedRaf {\n  if (typeof onFrame !== \"function\") {\n    throw new TypeError(\"createSuspendedRaf: onFrame is required\");\n  }\n\n  let rafId: number | null = null;\n  let running = false;\n  let destroyed = false;\n\n  const stopRaf = () => {\n    if (rafId != null) {\n      cancelAnimationFrame(rafId);\n      rafId = null;\n    }\n  };\n\n  const tick = (time: number) => {\n    rafId = null;\n    if (destroyed || !running || !gate.isActive) return;\n    onFrame(time);\n    if (!destroyed && running && gate.isActive) {\n      rafId = requestAnimationFrame(tick);\n    }\n  };\n\n  const sync = () => {\n    if (destroyed) return;\n    if (running && gate.isActive) {\n      if (rafId == null) {\n        rafId = requestAnimationFrame(tick);\n      }\n    } else {\n      stopRaf();\n    }\n  };\n\n  const gate = createVisibilityGate({\n    root,\n    rootMargin,\n    threshold,\n    observeTab,\n    observeOffscreen,\n    onChange: sync,\n  });\n\n  return {\n    /** Start (or resume) the loop when visibility allows. */\n    start() {\n      if (destroyed) return;\n      running = true;\n      sync();\n    },\n\n    /** Stop requesting frames (visibility listeners stay attached until destroy). */\n    stop() {\n      running = false;\n      stopRaf();\n    },\n\n    /** Whether the caller has started the loop (may still be paused by visibility). */\n    get isRunning() {\n      return running;\n    },\n\n    /** Whether a frame is currently allowed to schedule. */\n    get isActive() {\n      return gate.isActive;\n    },\n\n    /** Re-attach offscreen observer to a (new) root element. */\n    observe(nextRoot?: RafRoot) {\n      gate.observe(nextRoot);\n      sync();\n    },\n\n    /** Tear down listeners and cancel any pending frame. */\n    destroy() {\n      if (destroyed) return;\n      destroyed = true;\n      running = false;\n      stopRaf();\n      gate.destroy();\n    },\n  };\n}\n\nexport {\n  createSuspendedRaf,\n  createVisibilityGate,\n  DEFAULT_ROOT_MARGIN,\n};\n"
    }
  ]
}