{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A LiquidMetal component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  type CSSProperties,\n  type ReactNode,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nexport type LiquidMetalVariant = \"chrome\" | \"gold\" | \"mercury\" | \"oil\";\n\nexport interface LiquidMetalProps {\n  /** Content rendered above the metal surface, or clipped to it when `maskText` is on. */\n  children?: ReactNode;\n  className?: string;\n  /** Strength of the domain warp. `0` is an almost flat sheet, `2` is molten. */\n  distortion?: number;\n  /** Clip the material to the shape of the text instead of filling the box. */\n  maskText?: boolean;\n  /** Freeze the surface on its current frame. */\n  paused?: boolean;\n  /** Let the pointer push the flow field around. */\n  pointerInfluence?: boolean;\n  /** Time multiplier for the flow. */\n  speed?: number;\n  variant?: LiquidMetalVariant;\n}\n\nconst DEFAULT_SPEED = 1;\nconst DEFAULT_DISTORTION = 1;\nconst MAX_DPR = 2;\nconst MS_PER_SECOND = 1000;\nconst MAX_FRAME_DELTA = 1 / 20;\nconst POINTER_EASE = 0.08;\nconst POINTER_RANGE = 2;\nconst POINTER_CENTER = 0.5;\nconst TEXT_SWEEP_SECONDS = 9;\nconst TEXT_SWEEP_RANGE = 100;\nconst TEXT_POINTER_SHIFT = 18;\nconst TEXT_BACKGROUND_SIZE = \"300% 100%\";\nconst HALF = 0.5;\n\nconst VARIANT_INDEX: Record<LiquidMetalVariant, number> = {\n  chrome: 0,\n  gold: 1,\n  mercury: 2,\n  oil: 3,\n};\n\n/**\n * Static CSS stand-ins used when WebGL2 is unavailable or motion is reduced.\n * Hex is intentional here: these mirror the shader palettes, which are not\n * expressible with theme tokens.\n */\nconst FALLBACK_SURFACE: Record<LiquidMetalVariant, string> = {\n  chrome:\n    \"conic-gradient(from 210deg at 50% 50%, #0b0d10, #8f9aa8, #e9eef5, #6f7b8c, #cfd7e2, #14181d, #b9c3cf, #0b0d10)\",\n  gold: \"conic-gradient(from 210deg at 50% 50%, #3a2708, #b3832a, #f6dd8f, #8a5f16, #ffeeb5, #2a1c05, #d9a842, #3a2708)\",\n  mercury:\n    \"conic-gradient(from 200deg at 50% 50%, #10141a, #7f8b99, #dbe4ee, #55606d, #ffffff, #1b2028, #a6b2c0, #10141a)\",\n  oil: \"conic-gradient(from 190deg at 50% 50%, #1b1035, #3f7fd6, #38d0c0, #b9e05f, #f2a03d, #e0509b, #6b3fd6, #1b1035)\",\n};\n\nconst TEXT_SURFACE: Record<LiquidMetalVariant, string> = {\n  chrome:\n    \"linear-gradient(100deg, #4a545f 0%, #e9eef5 16%, #8f9aa8 32%, #ffffff 46%, #6f7b8c 62%, #dfe6ef 78%, #3c454f 100%)\",\n  gold: \"linear-gradient(100deg, #7a5410 0%, #f6dd8f 16%, #b3832a 32%, #fff6d0 46%, #8a5f16 62%, #edc862 78%, #6a460b 100%)\",\n  mercury:\n    \"linear-gradient(100deg, #55606d 0%, #dbe4ee 16%, #8f9aa8 32%, #ffffff 46%, #6b7684 62%, #cfd9e5 78%, #454f5b 100%)\",\n  oil: \"linear-gradient(100deg, #3f7fd6 0%, #38d0c0 16%, #b9e05f 32%, #f2a03d 46%, #e0509b 62%, #6b3fd6 78%, #3f7fd6 100%)\",\n};\n\nconst VERTEX_SHADER = `#version 300 es\nin vec2 a;\nvoid main(){ gl_Position = vec4(a, 0.0, 1.0); }`;\n\nconst FRAGMENT_SHADER = `#version 300 es\nprecision highp float;\nout vec4 fragColor;\nuniform vec2 uRes;\nuniform float uTime;\nuniform float uDistortion;\nuniform float uVariant;\nuniform vec2 uPointer;\n\nfloat hash(vec2 p){\n  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat noise(vec2 p){\n  vec2 i = floor(p);\n  vec2 f = fract(p);\n  float a = hash(i);\n  float b = hash(i + vec2(1.0, 0.0));\n  float c = hash(i + vec2(0.0, 1.0));\n  float d = hash(i + vec2(1.0, 1.0));\n  vec2 u = f * f * (3.0 - 2.0 * f);\n  return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;\n}\n\nfloat fbm(vec2 p){\n  float value = 0.0;\n  float amplitude = 0.5;\n  mat2 turn = mat2(1.6, 1.2, -1.2, 1.6);\n  for (int i = 0; i < 4; i++){\n    value += amplitude * noise(p);\n    p = turn * p;\n    amplitude *= 0.5;\n  }\n  return value;\n}\n\nvoid main(){\n  vec2 uv = gl_FragCoord.xy / uRes;\n  vec2 aspect = vec2(uRes.x / uRes.y, 1.0);\n  vec2 p = (uv - 0.5) * aspect * 2.2;\n  vec2 push = uPointer * 0.55;\n  float warp = clamp(uDistortion, 0.0, 3.0);\n\n  vec2 q = vec2(fbm(p + push), fbm(p + vec2(5.2, 1.3) - push));\n  vec2 flow = p + warp * 3.2 * q;\n  vec2 r = vec2(\n    fbm(flow + vec2(1.7, 9.2) + uTime * 0.17),\n    fbm(flow + vec2(8.3, 2.8) - uTime * 0.13)\n  );\n\n  vec2 base = p + warp * 2.8 * r;\n  float eps = 0.006;\n  float h = fbm(base);\n  float hx = fbm(base + vec2(eps, 0.0));\n  float hy = fbm(base + vec2(0.0, eps));\n  vec3 n = normalize(vec3((h - hx) / eps * 0.05, (h - hy) / eps * 0.05, 1.0));\n\n  vec3 view = normalize(vec3((uv - 0.5) * aspect, 1.0));\n  vec3 refl = reflect(view, n);\n  float f = clamp(refl.y * 0.5 + 0.5, 0.0, 1.0);\n  float sideways = clamp(refl.x * 0.5 + 0.5, 0.0, 1.0);\n\n  vec3 lo = vec3(0.04, 0.05, 0.07);\n  vec3 hi = vec3(0.90, 0.94, 0.99);\n  vec3 tint = vec3(0.58, 0.68, 0.85);\n\n  if (uVariant > 0.5 && uVariant < 1.5) {\n    lo = vec3(0.16, 0.10, 0.02);\n    hi = vec3(1.00, 0.90, 0.62);\n    tint = vec3(0.82, 0.58, 0.14);\n  } else if (uVariant > 1.5 && uVariant < 2.5) {\n    lo = vec3(0.06, 0.08, 0.10);\n    hi = vec3(1.00, 1.00, 1.00);\n    tint = vec3(0.50, 0.58, 0.68);\n  } else if (uVariant > 2.5) {\n    lo = vec3(0.05, 0.03, 0.14);\n    hi = vec3(0.85, 0.92, 1.00);\n    tint = vec3(0.35, 0.55, 0.90);\n  }\n\n  vec3 col = mix(lo, hi, smoothstep(0.04, 0.96, f));\n  col = mix(col, tint, pow(sideways, 2.0) * 0.55);\n\n  float spec = pow(max(dot(n, normalize(vec3(0.35, 0.75, 0.55))), 0.0), 42.0);\n  float fresnel = pow(1.0 - clamp(n.z, 0.0, 1.0), 3.0);\n  col += spec * 0.85 + fresnel * 0.20;\n\n  if (uVariant > 2.5) {\n    vec3 sheen = 0.5 + 0.5 * cos(6.28318 * (f + h * 0.75 + vec3(0.0, 0.33, 0.67)));\n    col = mix(col, sheen, 0.72);\n  }\n\n  fragColor = vec4(clamp(col, 0.0, 1.0), 1.0);\n}`;\n\nconst STRIP = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);\n\ninterface MetalController {\n  destroy: () => void;\n  setDistortion: (value: number) => void;\n  setPointer: (x: number, y: number) => void;\n  setRunning: (running: boolean) => void;\n  setSpeed: (value: number) => void;\n  setVariant: (value: LiquidMetalVariant) => void;\n}\n\nconst compileShader = (\n  gl: WebGL2RenderingContext,\n  type: number,\n  source: string\n) => {\n  const shader = gl.createShader(type);\n  if (!shader) {\n    return null;\n  }\n  gl.shaderSource(shader, source);\n  gl.compileShader(shader);\n  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n    gl.deleteShader(shader);\n    return null;\n  }\n  return shader;\n};\n\nconst resizeCanvas = (\n  canvas: HTMLCanvasElement,\n  gl: WebGL2RenderingContext\n) => {\n  const rect = canvas.getBoundingClientRect();\n  const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);\n  const width = Math.max(1, Math.round(rect.width * dpr));\n  const height = Math.max(1, Math.round(rect.height * dpr));\n  if (canvas.width !== width || canvas.height !== height) {\n    canvas.width = width;\n    canvas.height = height;\n  }\n  gl.viewport(0, 0, width, height);\n};\n\nconst createMetalController = (\n  canvas: HTMLCanvasElement,\n  initialVariant: LiquidMetalVariant,\n  initialSpeed: number,\n  initialDistortion: number\n): MetalController | null => {\n  const gl = canvas.getContext(\"webgl2\", {\n    alpha: false,\n    antialias: false,\n    powerPreference: \"low-power\",\n  });\n  if (!gl) {\n    return null;\n  }\n\n  const vertex = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);\n  const fragment = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);\n  const program = gl.createProgram();\n  if (!(vertex && fragment && program)) {\n    return null;\n  }\n\n  gl.attachShader(program, vertex);\n  gl.attachShader(program, fragment);\n  gl.linkProgram(program);\n  gl.deleteShader(vertex);\n  gl.deleteShader(fragment);\n  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n    gl.deleteProgram(program);\n    return null;\n  }\n\n  // biome-ignore lint/correctness/useHookAtTopLevel: WebGL2RenderingContext.useProgram is not a React hook.\n  gl.useProgram(program);\n\n  const buffer = gl.createBuffer();\n  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n  gl.bufferData(gl.ARRAY_BUFFER, STRIP, gl.STATIC_DRAW);\n  const position = gl.getAttribLocation(program, \"a\");\n  gl.enableVertexAttribArray(position);\n  gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);\n\n  const uRes = gl.getUniformLocation(program, \"uRes\");\n  const uTime = gl.getUniformLocation(program, \"uTime\");\n  const uDistortion = gl.getUniformLocation(program, \"uDistortion\");\n  const uVariant = gl.getUniformLocation(program, \"uVariant\");\n  const uPointer = gl.getUniformLocation(program, \"uPointer\");\n\n  let variant = initialVariant;\n  let speed = initialSpeed;\n  let distortion = initialDistortion;\n  let elapsed = 0;\n  let lastFrameAt = performance.now();\n  let frame = 0;\n  let running = false;\n  let destroyed = false;\n  let pointerX = 0;\n  let pointerY = 0;\n  let targetX = 0;\n  let targetY = 0;\n\n  const draw = () => {\n    resizeCanvas(canvas, gl);\n    gl.uniform2f(uRes, canvas.width, canvas.height);\n    gl.uniform1f(uTime, elapsed);\n    gl.uniform1f(uDistortion, distortion);\n    gl.uniform1f(uVariant, VARIANT_INDEX[variant]);\n    gl.uniform2f(uPointer, pointerX, pointerY);\n    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);\n  };\n\n  const tick = () => {\n    if (destroyed) {\n      return;\n    }\n    const now = performance.now();\n    const delta = Math.min(\n      (now - lastFrameAt) / MS_PER_SECOND,\n      MAX_FRAME_DELTA\n    );\n    lastFrameAt = now;\n    elapsed += delta * speed;\n    pointerX += (targetX - pointerX) * POINTER_EASE;\n    pointerY += (targetY - pointerY) * POINTER_EASE;\n    draw();\n    frame = requestAnimationFrame(tick);\n  };\n\n  draw();\n\n  return {\n    destroy: () => {\n      destroyed = true;\n      cancelAnimationFrame(frame);\n      if (buffer) {\n        gl.deleteBuffer(buffer);\n      }\n      gl.deleteProgram(program);\n      gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n    },\n    setDistortion: (value: number) => {\n      distortion = value;\n    },\n    setPointer: (x: number, y: number) => {\n      targetX = x;\n      targetY = y;\n    },\n    setRunning: (next: boolean) => {\n      if (destroyed || next === running) {\n        return;\n      }\n      running = next;\n      if (running) {\n        lastFrameAt = performance.now();\n        frame = requestAnimationFrame(tick);\n        return;\n      }\n      cancelAnimationFrame(frame);\n      draw();\n    },\n    setSpeed: (value: number) => {\n      speed = value;\n    },\n    setVariant: (value: LiquidMetalVariant) => {\n      variant = value;\n    },\n  };\n};\n\nconst LiquidMetal = ({\n  children,\n  className,\n  distortion = DEFAULT_DISTORTION,\n  maskText = false,\n  paused = false,\n  pointerInfluence = true,\n  speed = DEFAULT_SPEED,\n  variant = \"chrome\",\n}: LiquidMetalProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const wrapperRef = useRef<HTMLElement | null>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const textRef = useRef<HTMLSpanElement>(null);\n  const controllerRef = useRef<MetalController | null>(null);\n  const pointerRef = useRef({ x: 0, y: 0 });\n  const [isSupported, setIsSupported] = useState(true);\n  const [isInView, setIsInView] = useState(true);\n\n  const usesShader = !(maskText || shouldReduceMotion) && isSupported;\n  const isPlaying = !(paused || shouldReduceMotion) && isInView;\n\n  useEffect(() => {\n    const wrapper = wrapperRef.current;\n    if (!wrapper) {\n      return;\n    }\n    const observer = new IntersectionObserver((entries) => {\n      for (const entry of entries) {\n        setIsInView(entry.isIntersecting);\n      }\n    });\n    observer.observe(wrapper);\n    return () => observer.disconnect();\n  }, []);\n\n  // The GL context is rebuilt only when the rendering strategy changes; live\n  // uniform values are pushed through the setters in the effect below.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: initial uniform values must not restart the context.\n  useEffect(() => {\n    if (!usesShader) {\n      return;\n    }\n    const canvas = canvasRef.current;\n    if (!canvas) {\n      return;\n    }\n    const controller = createMetalController(\n      canvas,\n      variant,\n      speed,\n      distortion\n    );\n    if (!controller) {\n      setIsSupported(false);\n      return;\n    }\n    controllerRef.current = controller;\n    return () => {\n      controller.destroy();\n      controllerRef.current = null;\n    };\n  }, [usesShader]);\n\n  useEffect(() => {\n    const controller = controllerRef.current;\n    if (!controller) {\n      return;\n    }\n    controller.setVariant(variant);\n    controller.setSpeed(speed);\n    controller.setDistortion(distortion);\n  }, [distortion, speed, variant]);\n\n  useEffect(() => {\n    controllerRef.current?.setRunning(isPlaying);\n  }, [isPlaying]);\n\n  useEffect(() => {\n    const wrapper = wrapperRef.current;\n    if (!(wrapper && pointerInfluence) || shouldReduceMotion) {\n      return;\n    }\n    const hoverQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    if (!hoverQuery.matches) {\n      return;\n    }\n\n    const handleMove = (event: PointerEvent) => {\n      const rect = wrapper.getBoundingClientRect();\n      const x =\n        ((event.clientX - rect.left) / rect.width - POINTER_CENTER) *\n        POINTER_RANGE;\n      const y =\n        (POINTER_CENTER - (event.clientY - rect.top) / rect.height) *\n        POINTER_RANGE;\n      pointerRef.current = { x, y };\n      controllerRef.current?.setPointer(x, y);\n    };\n    const handleLeave = () => {\n      pointerRef.current = { x: 0, y: 0 };\n      controllerRef.current?.setPointer(0, 0);\n    };\n\n    wrapper.addEventListener(\"pointermove\", handleMove);\n    wrapper.addEventListener(\"pointerleave\", handleLeave);\n    return () => {\n      wrapper.removeEventListener(\"pointermove\", handleMove);\n      wrapper.removeEventListener(\"pointerleave\", handleLeave);\n    };\n  }, [pointerInfluence, shouldReduceMotion]);\n\n  useEffect(() => {\n    const node = textRef.current;\n    if (!(maskText && node) || shouldReduceMotion || paused || !isInView) {\n      return;\n    }\n    let frame = 0;\n    const startedAt = performance.now();\n    const step = () => {\n      const seconds =\n        ((performance.now() - startedAt) / MS_PER_SECOND) * Math.max(speed, 0);\n      const cycle = (seconds / TEXT_SWEEP_SECONDS) % 1;\n      const offset = pointerInfluence\n        ? pointerRef.current.x * TEXT_POINTER_SHIFT\n        : 0;\n      node.style.backgroundPosition = `${cycle * TEXT_SWEEP_RANGE + offset}% 50%`;\n      frame = requestAnimationFrame(step);\n    };\n    frame = requestAnimationFrame(step);\n    return () => cancelAnimationFrame(frame);\n  }, [isInView, maskText, paused, pointerInfluence, shouldReduceMotion, speed]);\n\n  if (maskText) {\n    const textStyle: CSSProperties = {\n      backgroundClip: \"text\",\n      backgroundImage: TEXT_SURFACE[variant],\n      backgroundPosition: `${TEXT_SWEEP_RANGE * HALF}% 50%`,\n      backgroundSize: TEXT_BACKGROUND_SIZE,\n      color: \"transparent\",\n      WebkitBackgroundClip: \"text\",\n      WebkitTextFillColor: \"transparent\",\n    };\n\n    return (\n      <span\n        className={cn(\"inline-block\", className)}\n        ref={(node) => {\n          wrapperRef.current = node;\n        }}\n      >\n        <span ref={textRef} style={textStyle}>\n          {children}\n        </span>\n      </span>\n    );\n  }\n\n  return (\n    <div\n      className={cn(\n        \"relative isolate overflow-hidden rounded-2xl bg-background\",\n        className\n      )}\n      ref={(node) => {\n        wrapperRef.current = node;\n      }}\n    >\n      {usesShader ? (\n        <div aria-hidden=\"true\" className=\"absolute inset-0 z-0\">\n          <canvas className=\"h-full w-full\" ref={canvasRef} />\n        </div>\n      ) : (\n        <div\n          aria-hidden=\"true\"\n          className=\"absolute inset-0 z-0\"\n          style={{ backgroundImage: FALLBACK_SURFACE[variant] }}\n        />\n      )}\n      <div className=\"relative z-10\">{children}</div>\n    </div>\n  );\n};\n\nexport default LiquidMetal;\n","path":"index.tsx","target":"components/smoothui/liquid-metal/index.tsx","type":"registry:ui"}],"name":"liquid-metal","registryDependencies":[],"title":"Liquid Metal","type":"registry:ui"}