{
  "name": "liquid-glass",
  "type": "registry:ui",
  "files": [
    {
      "path": "components/ui/liquid-glass.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type CSSProperties,\n  type HTMLAttributes,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type LiquidGlassProps = HTMLAttributes<HTMLDivElement> & {\n  contentClassName?: string;\n  /** Corner radius in px. Default 999 (pill). */\n  radius?: number;\n  /** Rim band that bends light, in px. */\n  bevelDepth?: number;\n  /** Displacement strength in px. */\n  scale?: number;\n  /** Chromatic aberration spread in px. */\n  aberration?: number;\n  /** Extra blur after refraction, in px. */\n  frost?: number;\n  /** White body tint 0–1. */\n  tint?: number;\n};\n\n/**\n * Liquid glass surface. A Snell's-law displacement map bends whatever sits\n * behind the element through an SVG filter used as `backdrop-filter`.\n * Chromium refracts; Safari, Firefox and reduced-transparency fall back to\n * frosted glass.\n */\nexport function LiquidGlass({\n  children,\n  className,\n  contentClassName,\n  radius = 999,\n  bevelDepth = 14,\n  scale = 48,\n  aberration = 4,\n  frost = 0.4,\n  tint = 0.12,\n  style,\n  ...rest\n}: LiquidGlassProps) {\n  const reactId = useId().replace(/:/g, \"\");\n  const rootRef = useRef<HTMLDivElement>(null);\n  const blobRef = useRef<string | null>(null);\n  const [size, setSize] = useState({ w: 0, h: 0 });\n  const [filter, setFilter] = useState<{ id: string; svg: string } | null>(\n    null,\n  );\n  const [mode, setMode] = useState<\"pending\" | \"simple\" | \"opaque\">(\"pending\");\n\n  useEffect(() => {\n    if (prefersReducedTransparency()) setMode(\"opaque\");\n    else if (isSafariLike() || !supportsUrlBackdropFilter()) setMode(\"simple\");\n  }, []);\n\n  useEffect(() => {\n    const el = rootRef.current;\n    if (!el || mode !== \"pending\") return;\n    const ro = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      // Border box, so the map matches the backdrop-filter layer.\n      const box = entry.borderBoxSize?.[0];\n      const width = box?.inlineSize ?? entry.contentRect.width;\n      const height = box?.blockSize ?? entry.contentRect.height;\n      setSize({\n        w: Math.max(1, Math.round(width)),\n        h: Math.max(1, Math.round(height)),\n      });\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [mode]);\n\n  useEffect(() => {\n    if (mode !== \"pending\" || size.w <= 1 || size.h <= 1) return;\n    let cancelled = false;\n\n    (async () => {\n      const mapUrl = await generateDisplacementBlobUrl({\n        width: size.w,\n        height: size.h,\n        radius: Math.min(radius, size.w / 2, size.h / 2),\n        bevelDepth: Math.min(bevelDepth, Math.min(size.w, size.h) / 2),\n        dpr: Math.min(window.devicePixelRatio || 1, 2),\n      });\n      // Decode the map before the filter attaches.\n      await new Promise<void>((resolve) => {\n        const img = new Image();\n        img.onload = img.onerror = () => resolve();\n        img.src = mapUrl;\n      });\n      if (cancelled) {\n        URL.revokeObjectURL(mapUrl);\n        return;\n      }\n      if (blobRef.current) URL.revokeObjectURL(blobRef.current);\n      blobRef.current = mapUrl;\n\n      // Fresh id each rebuild, because browsers cache filter output by id.\n      const id = `lg-${reactId}-${size.w}x${size.h}-${Date.now().toString(36)}`;\n      setFilter({\n        id,\n        svg: buildFilterSvg({\n          id,\n          mapUrl,\n          width: size.w,\n          height: size.h,\n          scale,\n          aberration,\n          frost,\n        }),\n      });\n    })();\n\n    return () => {\n      cancelled = true;\n    };\n  }, [mode, size.w, size.h, radius, bevelDepth, scale, aberration, frost, reactId]);\n\n  useEffect(\n    () => () => {\n      if (blobRef.current) URL.revokeObjectURL(blobRef.current);\n    },\n    [],\n  );\n\n  const backdrop =\n    mode === \"opaque\"\n      ? \"none\"\n      : mode === \"simple\"\n        ? \"blur(20px) saturate(1.4)\"\n        : filter\n          ? `url(#${filter.id}) blur(0.5px) saturate(1.85) brightness(1.02)`\n          : \"blur(10px) saturate(1.6) brightness(1.04)\";\n\n  return (\n    <div\n      ref={rootRef}\n      data-refract={mode === \"pending\" && filter ? \"true\" : \"false\"}\n      className={cn(\n        \"relative overflow-hidden\",\n        mode === \"simple\" && \"bg-white/70 dark:bg-neutral-900/60\",\n        mode === \"opaque\" && \"bg-white dark:bg-neutral-900\",\n        className,\n      )}\n      style={{\n        borderRadius: radius,\n        backgroundColor:\n          mode === \"pending\" ? `rgb(255 255 255 / ${tint})` : undefined,\n        backdropFilter: backdrop,\n        WebkitBackdropFilter: backdrop,\n        ...style,\n      }}\n      {...rest}\n    >\n      {filter ? (\n        <span\n          key={filter.id}\n          aria-hidden\n          className=\"pointer-events-none absolute size-0 overflow-hidden\"\n          dangerouslySetInnerHTML={{ __html: filter.svg }}\n        />\n      ) : null}\n      {contentClassName ? (\n        <div className={contentClassName}>{children}</div>\n      ) : (\n        children\n      )}\n    </div>\n  );\n}\n\nfunction supportsUrlBackdropFilter() {\n  if (typeof CSS === \"undefined\" || !CSS.supports) return false;\n  return (\n    CSS.supports(\"backdrop-filter\", \"url(#x)\") ||\n    CSS.supports(\"-webkit-backdrop-filter\", \"url(#x)\")\n  );\n}\n\nfunction prefersReducedTransparency() {\n  if (typeof window === \"undefined\" || !window.matchMedia) return false;\n  return window.matchMedia(\"(prefers-reduced-transparency: reduce)\").matches;\n}\n\nfunction isSafariLike() {\n  if (typeof navigator === \"undefined\") return false;\n  const ua = navigator.userAgent;\n  return /Safari/.test(ua) && !/Chrome|Chromium|Edg|OPR/.test(ua);\n}\n\n/* ---------------------------------------------------------------------------\n * Displacement map\n * R = horizontal bend, G = vertical bend, B = rim specular, A = shape mask.\n * Neutral grey (128) means zero displacement.\n * ------------------------------------------------------------------------- */\n\ntype DisplacementOptions = {\n  width: number;\n  height: number;\n  radius: number;\n  bevelDepth: number;\n  gain?: number;\n  light?: { x: number; y: number };\n  dpr?: number;\n};\n\n/** Signed distance to a rounded box centred at the origin. <0 inside. */\nfunction sdRoundedBox(\n  px: number,\n  py: number,\n  halfW: number,\n  halfH: number,\n  r: number,\n) {\n  const qx = Math.abs(px) - halfW + r;\n  const qy = Math.abs(py) - halfH + r;\n  const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0));\n  const inside = Math.min(Math.max(qx, qy), 0);\n  return inside + outside - r;\n}\n\n/** Squircle-dome slope → Snell's-law bend. `x` = 0 at the rim, 1 at centre. */\nfunction bendFromDepth(x: number) {\n  const u = 1 - Math.min(1, Math.max(0, x));\n  const denom = (1 - u ** 4) ** 0.75;\n  if (denom < 1e-6) return 0;\n  const thetaI = Math.atan(Math.min(u ** 3 / denom, 8));\n  const sinT = Math.sin(thetaI) / 1.5;\n  if (sinT >= 1) return Math.sin(thetaI);\n  return Math.sin(thetaI - Math.asin(sinT));\n}\n\n/** φ(d) = ∫ bend along the distance from the rim. */\nfunction buildPhiTable(bevelPx: number) {\n  const n = Math.max(1, Math.ceil(bevelPx));\n  const phi = new Float32Array(n + 1);\n  for (let d = 1; d <= n; d++) phi[d] = phi[d - 1] + bendFromDepth(d / n);\n  return phi;\n}\n\nfunction samplePhi(phi: Float32Array, depth: number) {\n  const max = phi.length - 1;\n  if (depth <= 0) return phi[0];\n  if (depth >= max) return phi[max];\n  const i = Math.floor(depth);\n  const f = depth - i;\n  return phi[i] * (1 - f) + phi[i + 1] * f;\n}\n\nfunction computeDisplacementData(opts: DisplacementOptions) {\n  const dpr = opts.dpr ?? 1;\n  const gain = opts.gain ?? 1.35;\n  const light = opts.light ?? { x: -0.55, y: -0.85 };\n  const lightLen = Math.hypot(light.x, light.y) || 1;\n  const lx = light.x / lightLen;\n  const ly = light.y / lightLen;\n\n  const W = Math.max(1, Math.round(opts.width * dpr));\n  const H = Math.max(1, Math.round(opts.height * dpr));\n  const halfW = W / 2;\n  const halfH = H / 2;\n  const r = Math.min(opts.radius * dpr, halfW, halfH);\n  const bevel = Math.max(1, opts.bevelDepth * dpr);\n  const phi = buildPhiTable(bevel);\n\n  // Height field over the shape, then displace along its gradient.\n  const field = new Float32Array(W * H);\n  for (let y = 0; y < H; y++) {\n    const py = y - halfH + 0.5;\n    for (let x = 0; x < W; x++) {\n      const d = sdRoundedBox(x - halfW + 0.5, py, halfW, halfH, r);\n      field[y * W + x] = d > 0 ? 0 : samplePhi(phi, Math.min(bevel, -d));\n    }\n  }\n\n  const data = new Uint8ClampedArray(W * H * 4);\n  for (let y = 0; y < H; y++) {\n    const py = y - halfH + 0.5;\n    for (let x = 0; x < W; x++) {\n      const px = x - halfW + 0.5;\n      const i = (y * W + x) << 2;\n      const d = sdRoundedBox(px, py, halfW, halfH, r);\n\n      if (d > 0) {\n        data[i] = data[i + 1] = data[i + 2] = 128;\n        data[i + 3] = 0;\n        continue;\n      }\n\n      const x0 = Math.max(0, x - 1);\n      const x1 = Math.min(W - 1, x + 1);\n      const y0 = Math.max(0, y - 1);\n      const y1 = Math.min(H - 1, y + 1);\n      const dPhiX = (field[y * W + x1] - field[y * W + x0]) / (x1 - x0 || 1);\n      const dPhiY = (field[y1 * W + x] - field[y0 * W + x]) / (y1 - y0 || 1);\n\n      // Cap so the rim compresses but never folds over itself.\n      const dx = Math.max(-1, Math.min(1, dPhiX * gain));\n      const dy = Math.max(-1, Math.min(1, dPhiY * gain));\n\n      let specular = 0;\n      if (d >= -bevel) {\n        const gx =\n          sdRoundedBox(px + 1, py, halfW, halfH, r) -\n          sdRoundedBox(px - 1, py, halfW, halfH, r);\n        const gy =\n          sdRoundedBox(px, py + 1, halfW, halfH, r) -\n          sdRoundedBox(px, py - 1, halfW, halfH, r);\n        const len = Math.hypot(gx, gy) || 1;\n        const dot = (gx / len) * lx + (gy / len) * ly;\n        const rim = 1 - Math.min(1, -d / bevel);\n        specular = (Math.max(0, dot) + Math.max(0, -dot) * 0.35) * rim;\n      }\n\n      data[i] = 128 + dx * 127;\n      data[i + 1] = 128 + dy * 127;\n      data[i + 2] = Math.round(specular * 255);\n      data[i + 3] = 255;\n    }\n  }\n\n  return { data, width: W, height: H };\n}\n\n/** PNG blob URL for feImage (WebKit rejects data: URIs inside feImage). */\nasync function generateDisplacementBlobUrl(opts: DisplacementOptions) {\n  const { data, width, height } = computeDisplacementData(opts);\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = width;\n  canvas.height = height;\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) throw new Error(\"2d context unavailable\");\n  ctx.putImageData(new ImageData(data, width, height), 0, 0);\n  const blob = await new Promise<Blob | null>((resolve) =>\n    canvas.toBlob(resolve, \"image/png\"),\n  );\n  if (!blob) throw new Error(\"Failed to encode displacement map\");\n  return URL.createObjectURL(blob);\n}\n\n/* ---------------------------------------------------------------------------\n * SVG filter\n * ------------------------------------------------------------------------- */\n\ntype FilterOptions = {\n  id: string;\n  mapUrl: string;\n  width: number;\n  height: number;\n  scale: number;\n  aberration: number;\n  frost: number;\n};\n\nfunction buildFilterSvg({\n  id,\n  mapUrl,\n  width,\n  height,\n  scale,\n  aberration,\n  frost,\n}: FilterOptions) {\n  const pad = Math.ceil(scale + aberration + 4);\n  const px = (pad / Math.max(1, width)) * 100;\n  const py = (pad / Math.max(1, height)) * 100;\n  const region = `x=\"-${px.toFixed(3)}%\" y=\"-${py.toFixed(3)}%\" width=\"${(100 + 2 * px).toFixed(3)}%\" height=\"${(100 + 2 * py).toFixed(3)}%\"`;\n\n  // Pixel-sized map matched to the lens; % units wash out the rim bend.\n  const map = `<feImage href=\"${mapUrl}\" xlink:href=\"${mapUrl}\" x=\"0\" y=\"0\" width=\"${width}\" height=\"${height}\" result=\"map\" preserveAspectRatio=\"none\"/>`;\n  const displace = (s: number, result: string) =>\n    `<feDisplacementMap in=\"SourceGraphic\" in2=\"map\" scale=\"${s}\" xChannelSelector=\"R\" yChannelSelector=\"G\" result=\"${result}\"/>`;\n\n  // One pass without aberration, so no screen-blend seams.\n  const body =\n    aberration <= 0\n      ? `${displace(scale, \"rgb\")}`\n      : `${displace(scale + aberration, \"dispR\")}\n  <feColorMatrix in=\"dispR\" type=\"matrix\" values=\"1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0\" result=\"red\"/>\n  ${displace(scale, \"dispG\")}\n  <feColorMatrix in=\"dispG\" type=\"matrix\" values=\"0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 1 0\" result=\"green\"/>\n  ${displace(Math.max(0, scale - aberration), \"dispB\")}\n  <feColorMatrix in=\"dispB\" type=\"matrix\" values=\"0 0 0 0 0  0 0 0 0 0  0 0 1 0 0  0 0 0 1 0\" result=\"blue\"/>\n  <feBlend in=\"red\" in2=\"green\" mode=\"screen\" result=\"rg\"/>\n  <feBlend in=\"rg\" in2=\"blue\" mode=\"screen\" result=\"rgb\"/>`;\n\n  return `<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"0\" height=\"0\" aria-hidden=\"true\" style=\"position:absolute;width:0;height:0;overflow:hidden\"><filter id=\"${id}\" color-interpolation-filters=\"sRGB\" ${region}>\n  ${map}\n  ${body}\n  <feGaussianBlur in=\"rgb\" stdDeviation=\"${frost}\"/>\n</filter></svg>`;\n}\n",
      "type": "registry:ui",
      "target": "components/ui/liquid-glass.tsx"
    }
  ],
  "author": "Manu Arora <hi@manuarora.in>",
  "title": "Liquid Glass"
}