"use client";
import * as React from "react";
export type GlassSurface = "lens" | "liquid";
export interface GlassRefractionProps extends React.ComponentPropsWithoutRef<"div"> {
/** Refraction strength: the displacement scale in px (0 turns it off). */
distortion?: number;
/** Frost, in px. The fallback path (no SVG backdrop filters) adds 8px so it still reads as glass. */
blur?: number;
/** Strength of the surface tint, 0–1 (white on light themes, near-black on dark ones). */
tint?: number;
/** Specular edge highlight intensity, 0–1. */
edge?: number;
/** Corner radius, in px. Use half the size for a round lens. */
radius?: number;
/** `lens`: a convex bezel that bends content near the rim. `liquid`: a turbulent, rippled pane. */
surface?: GlassSurface;
/** Move the specular highlight toward the pointer (mouse and pen). */
followPointer?: boolean;
/** Overrides the theme tint colour (any CSS colour). */
tintColor?: string;
}
/* ---------------------------- Feature detection ---------------------------- */
const noopSubscribe = () => () => {};
/**
* `backdrop-filter: url(#svg-filter)` only renders in Chromium (Chrome, Edge, Opera, Arc…).
* Firefox and Safari parse or ignore it without refracting, so they get the frosted fallback.
*/
function supportsSvgBackdrop() {
if (typeof CSS === "undefined" || !CSS.supports("backdrop-filter", "url(#a)")) return false;
const brands = (navigator as Navigator & { userAgentData?: { brands?: { brand: string }[] } }).userAgentData?.brands;
if (brands) return brands.some((b) => b.brand === "Chromium");
return /\bChrome\/\d+/.test(navigator.userAgent) && !/Firefox|FxiOS|CriOS|EdgiOS/.test(navigator.userAgent);
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* ------------------------------ Lens map ------------------------------------ */
const MAP_SCALE = 0.5; // the displacement map is drawn at half resolution and stretched
/**
* Builds a displacement map for a rounded rectangle: R/G encode an x/y offset (128 = none).
* Inside a bezel band along the rim, samples are pulled from further inside the panel — strongest
* at the edge, easing to zero — so content bends and magnifies toward the rim like thick glass.
*/
function buildLensMap(width: number, height: number, radius: number): string {
const w = Math.max(2, Math.round(width * MAP_SCALE));
const h = Math.max(2, Math.round(height * MAP_SCALE));
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return "";
const img = ctx.createImageData(w, h);
const r = Math.min(radius * MAP_SCALE, w / 2, h / 2);
const bezel = Math.max(6, Math.min(w, h) * 0.22);
const hw = w / 2;
const hh = h / 2;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
// Signed distance to the rounded rect (negative inside) and its outward normal.
const px = x + 0.5 - hw;
const py = y + 0.5 - hh;
const qx = Math.abs(px) - (hw - r);
const qy = Math.abs(py) - (hh - r);
let nx: number;
let ny: number;
let dist: number;
if (qx > 0 && qy > 0) {
const len = Math.sqrt(qx * qx + qy * qy) || 1;
dist = len - r;
nx = qx / len;
ny = qy / len;
} else if (qx > qy) {
dist = qx - r;
nx = 1;
ny = 0;
} else {
dist = qy - r;
nx = 0;
ny = 1;
}
nx *= Math.sign(px) || 1;
ny *= Math.sign(py) || 1;
const depth = -dist; // 0 at the rim, growing inward
const t = depth < bezel ? 1 - Math.max(0, depth) / bezel : 0;
const m = t * t * (3 - 2 * t); // smooth falloff from rim to the flat centre
const o = (y * w + x) * 4;
// Pull samples inward: offset opposite to the outward normal.
img.data[o] = 128 - nx * m * 127;
img.data[o + 1] = 128 - ny * m * 127;
img.data[o + 2] = 128;
img.data[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
return canvas.toDataURL();
}
/* -------------------------------- Component -------------------------------- */
const ringMask: React.CSSProperties = {
// Keeps only the 1.5px padding ring: the content box is cut out of the full box.
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
};
/**
* A "liquid glass" pane that refracts whatever sits behind it. In Chromium the backdrop runs
* through an inline SVG filter — feDisplacementMap driven by a generated bezel map (`lens`) or by
* feTurbulence noise (`liquid`) — then a light blur and saturation. Other browsers get a frosted
* `backdrop-filter: blur()` fallback. A specular rim highlight leans toward the pointer, and the
* children render crisply on top, untouched by the filter.
*/
export function GlassRefraction({
distortion = 40,
blur = 2,
tint = 0.2,
edge = 0.6,
radius = 24,
surface = "lens",
followPointer = true,
tintColor,
className = "",
style,
children,
...props
}: GlassRefractionProps) {
const reduceMotion = usePrefersReducedMotion();
const svgBackdrop = React.useSyncExternalStore(noopSubscribe, supportsSvgBackdrop, () => false);
const rawId = React.useId();
const filterId = `glass-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const rootRef = React.useRef<HTMLDivElement>(null);
const [box, setBox] = React.useState({ width: 0, height: 0 });
const [lensMap, setLensMap] = React.useState("");
const refract = svgBackdrop && distortion > 0;
const w = Math.round(box.width);
const h = Math.round(box.height);
// Track the pane's size: the SVG filter works in pixels, so it must match the element.
React.useEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setBox((prev) => (Math.abs(prev.width - width) < 1 && Math.abs(prev.height - height) < 1 ? prev : { width, height }));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// Regenerate the bezel map (only on the Chromium path, for the lens surface).
React.useEffect(() => {
if (!refract || surface !== "lens" || w < 2 || h < 2) return;
const frame = requestAnimationFrame(() => setLensMap(buildLensMap(w, h, radius)));
return () => cancelAnimationFrame(frame);
}, [refract, surface, w, h, radius]);
// Specular highlight: CSS variables updated from the window pointer, one write per frame.
React.useEffect(() => {
const el = rootRef.current;
if (!el || !followPointer || reduceMotion) return;
let raf = 0;
let cx = 0;
let cy = 0;
const apply = () => {
raf = 0;
const rect = el.getBoundingClientRect();
if (!rect.width || !rect.height) return;
// Clamp a little outside the pane, so the light slides along the rim nearest the pointer.
const x = Math.min(1.15, Math.max(-0.15, (cx - rect.left) / rect.width));
const y = Math.min(1.15, Math.max(-0.15, (cy - rect.top) / rect.height));
el.style.setProperty("--gr-x", `${(x * 100).toFixed(1)}%`);
el.style.setProperty("--gr-y", `${(y * 100).toFixed(1)}%`);
};
const onMove = (e: PointerEvent) => {
if (e.pointerType === "touch") return;
cx = e.clientX;
cy = e.clientY;
if (!raf && !document.hidden) raf = requestAnimationFrame(apply);
};
let listening = false;
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && !listening) window.addEventListener("pointermove", onMove, { passive: true });
if (!entry.isIntersecting && listening) window.removeEventListener("pointermove", onMove);
listening = entry.isIntersecting;
});
io.observe(el);
return () => {
io.disconnect();
window.removeEventListener("pointermove", onMove);
cancelAnimationFrame(raf);
el.style.removeProperty("--gr-x");
el.style.removeProperty("--gr-y");
};
}, [followPointer, reduceMotion]);
const frost = Math.max(0, blur);
const fallback = `blur(${frost + 8}px) saturate(1.6)`;
const backdrop = refract ? `url(#${filterId}) blur(${frost}px) saturate(1.5)` : fallback;
const tintAmount = `${Math.round(Math.min(1, Math.max(0, tint)) * 100)}%`;
const shine = Math.min(1, Math.max(0, edge));
const mapReady = surface === "liquid" || lensMap !== "";
return (
<div
ref={rootRef}
className={`relative ${className}`}
style={{
borderRadius: radius,
["--gr-x" as string]: "28%",
["--gr-y" as string]: "0%",
["--gr-edge" as string]: shine,
["--gr-tint" as string]: tintAmount,
...(tintColor ? { ["--gr-tint-color" as string]: tintColor } : null),
...style,
}}
data-refraction={refract ? "svg" : "frost"}
{...props}
>
{refract && w > 1 && h > 1 && (
<svg aria-hidden="true" width="0" height="0" className="absolute">
<filter id={filterId} colorInterpolationFilters="sRGB">
{surface === "lens" ? (
lensMap && <feImage href={lensMap} x="0" y="0" width={w} height={h} preserveAspectRatio="none" result="map" />
) : (
<>
<feTurbulence type="fractalNoise" baseFrequency={(1.6 / Math.max(80, Math.min(w, h))).toFixed(4)} numOctaves={2} seed={7} result="noise" />
<feGaussianBlur in="noise" stdDeviation="2" result="map" />
</>
)}
<feDisplacementMap in="SourceGraphic" in2="map" scale={distortion} xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>
)}
{/* The glass: refracted (or frosted) backdrop plus a theme tint. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden [--gr-tint-fallback:#ffffff] dark:[--gr-tint-fallback:#0c0e10]"
style={{
borderRadius: "inherit",
backdropFilter: mapReady ? backdrop : fallback,
// Older Safari only knows the prefixed property; Chromium must not get a second value.
WebkitBackdropFilter: refract ? undefined : fallback,
backgroundColor: "color-mix(in oklab, var(--gr-tint-color, var(--gr-tint-fallback)) var(--gr-tint), transparent)",
}}
/>
{/* Rim: a fixed hairline plus inner light and shade, then a soft drop shadow. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 border border-white/45 shadow-[inset_0_1px_0_rgb(255_255_255/0.55),inset_0_-1px_1px_rgb(0_0_0/0.08),0_18px_40px_-18px_rgb(0_0_0/0.35)] dark:border-white/[0.14] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.16),inset_0_-1px_1px_rgb(0_0_0/0.4),0_22px_50px_-20px_rgb(0_0_0/0.7)]"
style={{ borderRadius: "inherit" }}
/>
{/* Specular: a bright ring segment, a softer inner rim glow and a faint sheen, all centred on the light. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 p-[1.5px]"
style={{
...ringMask,
borderRadius: "inherit",
opacity: "var(--gr-edge)",
background: "radial-gradient(60% 70% at var(--gr-x) var(--gr-y), rgb(255 255 255 / 0.95), rgb(255 255 255 / 0.25) 45%, transparent 75%)",
}}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 p-[7px] blur-[3px]"
style={{
...ringMask,
borderRadius: "inherit",
opacity: "calc(var(--gr-edge) * 0.55)",
background: "radial-gradient(55% 65% at var(--gr-x) var(--gr-y), rgb(255 255 255 / 0.7), transparent 70%)",
}}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{
borderRadius: "inherit",
opacity: "calc(var(--gr-edge) * 0.5)",
background: "radial-gradient(70% 55% at var(--gr-x) var(--gr-y), rgb(255 255 255 / 0.22), transparent 70%)",
}}
/>
{/* Content stays crisp: the filters only ever touch the backdrop. */}
<div className="relative h-full w-full">{children}</div>
</div>
);
}