// Adapted from samasante/liquid-glass 4e7b769e1df7e5a7d3669fef22417fe3d2f79ade (MIT). See LICENSE.txt. import React, { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { type GlassOptics, DEFAULT_LENS_PARAMS, DISPERSION_SPREAD, type LensMapGenerator, createLensMapGenerator, matrixForAxisScale, } from "./displacement.js"; import { type GlassValue, isGlassMotionValue, readGlassValue } from "./signal.js"; /** * Material mode — the friendly default for `children`. * * You style the wrapper however you like (a translucent `background` = the tint, * a `border-radius`, padding, a size — via `className` / `style` / Tailwind) and * this turns that box into glass: it frosts and refracts the LIVE page behind it, * the translucent colour reads as a tint over the refraction, a soft bright edge * rims it, and the children render crisp on top. * * The mechanism is one CSS property: `backdrop-filter`. It filters whatever is * painted BEHIND the element (never the element's own content), so: * • `blur()` + `saturate()` frost the backdrop — cross-browser. * • ` url(#…)` runs an SVG displacement filter on the backdrop — the liquid * refraction — which ships in Blink (Chrome/Edge) only. Safari and Firefox * get the frost + tint + edge; the bend needs a copyable backdrop (an in-place * `` over its own content, or `refract`), so material mode is honest about it. * * Because the colour you set is the glass tint, it must be TRANSLUCENT — a solid * `background` is opaque and hides the refraction (not glass). We dev-warn when * the computed background is fully opaque. */ /** * Does this engine support `backdrop-filter: url(#…)` (a custom SVG filter on the * backdrop)? Blink (Chrome/Edge/Opera) does; WebKit (Safari) and Gecko (Firefox) * support `backdrop-filter: blur()` but NOT `url()`. `@supports`/probe-element * tests are unreliable here (engines parse the `url()` syntax without rendering * it), so sniff the engine — the same approach `useIsWebKit` uses. * * We bias toward a false NEGATIVE: if we're unsure, we leave `url()` OFF. A * wrongly-disabled Blink just loses the bend (still frosts); a wrongly-ENABLED * Safari would get `url()` in the value, which is invalid there and drops the * WHOLE `backdrop-filter` — no frost at all. So only enable when confident. * * SSR-safe: `false` on the server + first client render (so hydration matches), * then settles on mount. */ const useSupportsBackdropUrl = () => { const [ok, setOk] = useState(false); useEffect(() => { if (typeof navigator === "undefined") return; const ua = navigator.userAgent; // Chromium exposes `navigator.userAgentData`; Safari and Firefox do not. // On iOS every browser is really WebKit (CriOS / EdgiOS / FxiOS) with no // userAgentData and no `url()` support, so this rules them out too. The UA // fallback covers Chromium builds with userAgentData disabled. const hasUAData = (navigator as Navigator & { userAgentData?: unknown }).userAgentData != null; const isBlink = hasUAData || (/\b(?:Chrome|Chromium|Edg)\//.test(ua) && !/\b(?:CriOS|EdgiOS|FxiOS|OPiOS)\b/.test(ua) && !/iPhone|iPad|iPod/.test(ua)); setOk(isBlink); }, []); return ok; }; /** * The material default look — a subtle glass SURFACE (not a magnifying loupe). * Gentle body, a soft rim meniscus, light chromatic edge, a directional sheen. * `frost` here is the backdrop blur in PX (the CSS `blur()` radius), unlike the * tiny obb/px frost the copy-based engine uses. Override any field via `optics`. */ export const MATERIAL_OPTICS: Partial = { strength: 0.05, depth: 0.5, curvature: 0.3, bend: 0.45, bendWidth: 0.16, dispersion: 0.32, frost: 6, saturate: 1.15, sheen: 0.32, sheenWidth: 3, sheenFalloff: 1.5, glow: 0.1, glowSpread: 1, glowFalloff: 0.5, specular: 1, sheenAngle: 45, brightness: 0, }; /** * The displacement + dispersion + specular primitive chain for the BACKDROP-filter * context. `SourceGraphic` here IS the backdrop (the live page behind the * element), so this refracts it directly — no copy. Simpler than the copy-based * `LensFilterContents`: the element's own border-box (+ radius) clips the result, * so there's no shape-cutout, and the frost lives in the CSS `blur()` ahead of * this filter, so there's no in-filter blur. userSpaceOnUse throughout (px). */ const MaterialFilterContents: React.FC<{ /** Displacement scale in px (already obb-normalized: strength × diagonal). */ dispScale: number; dispersion: number; /** B-channel specular gain (sheen + glow); 0 → skip the specular pass. */ specular: number; hasSpecular: boolean; /** Per-axis map rescale around 0.5 (anisotropic lenses); null → none. */ mapMatrix: string | null; width: number; height: number; mapUrl: string; feImageRef: React.Ref; }> = ({ dispScale, dispersion, specular, hasSpecular, mapMatrix, width, height, mapUrl, feImageRef, }) => { const mapInput = mapMatrix ? "scaledMap" : "map"; return ( <> {/* Back the map with neutral grey (displacement 0) across the whole, margin- extended region: the feImage only covers the box, so without this the map is transparent-black outside it and the displacement resampling biases the box edge toward a dark/contorted fringe. Mirrors LensFilterContents. */} {mapMatrix && ( )} {dispersion > 0 ? ( <> ) : ( )} {hasSpecular && ( <> {/* Lift the map's B channel into a bright sheen mask (128→0, 255→1), then add it over the refracted backdrop — the directional rim shine + soft inner glow, the same specular the rest of the library uses. */} )} ); }; export interface GlassMaterialProps extends Omit< React.HTMLAttributes, "children" > { children?: React.ReactNode; optics?: Partial; /** Corner radius in px. Omit → inherit the wrapper's computed border-radius. */ radius?: GlassValue; /** Explicit box size in px (usually you size it with CSS/className instead). */ width?: GlassValue; height?: GlassValue; } const num = (v: GlassValue | undefined): number | undefined => v == null ? undefined : isGlassMotionValue(v) ? readGlassValue(v) : v; export const GlassMaterial: React.FC = ({ children, optics, radius, width, height, className, style, ...rest }) => { const supportsUrl = useSupportsBackdropUrl(); const merged = useMemo( () => ({ ...DEFAULT_LENS_PARAMS, ...MATERIAL_OPTICS, ...optics }), [optics], ); const baseId = useId().replace(/:/g, ""); const wrapRef = useRef(null); const filterRef = useRef(null); const feImageRef = useRef(null); const generatorRef = useRef<{ gen: LensMapGenerator; size: number } | null>( null, ); const mapUrlRef = useRef(""); const versionRef = useRef(0); // Measured box + the radius to round the lens map / edge to. Precedence: // explicit `radius` prop → the wrapper's own border-radius (className/style) → // the wrapped child's radius (so `