Liquid Glass Cursor
A refractive glass lens that trails your cursor, bending and magnifying the content beneath it — shader optics without WebGL
cursorglassrefractionlensliquid-glass
Install dependencies
$npm install framer-motionPreview
Source Code
"use client";
import { useEffect, useId, useRef, useState } from "react";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "framer-motion";
interface LiquidGlassCursorProps {
children?: React.ReactNode;
size?: number;
magnification?: number;
distortion?: number;
aberration?: number;
className?: string;
}
export default function LiquidGlassCursor({
children,
size = 150,
magnification = 1.15,
distortion = 60,
aberration = 1,
className = "",
}: LiquidGlassCursorProps) {
const ref = useRef<HTMLDivElement>(null);
const rawId = useId();
const filterId = `lgc-${rawId.replace(/[^a-zA-Z0-9]/g, "")}`;
const [dims, setDims] = useState({ width: 0, height: 0 });
const [copyStyle, setCopyStyle] = useState<React.CSSProperties>({});
const [mapUrl, setMapUrl] = useState("");
const reduceMotion = useReducedMotion();
const x = useMotionValue(0);
const y = useMotionValue(0);
const presence = useMotionValue(0);
const springConfig = reduceMotion
? { stiffness: 2000, damping: 90 }
: { stiffness: 300, damping: 26 };
const sx = useSpring(x, springConfig);
const sy = useSpring(y, springConfig);
const scale = useSpring(presence, { stiffness: 260, damping: 20 });
// Radial displacement map: neutral (128,128) at center, pulling samples
// toward the lens center with cubic falloff so the bulge lives at the rim.
useEffect(() => {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const img = ctx.createImageData(size, size);
const c = size / 2;
for (let py = 0; py < size; py++) {
for (let px = 0; px < size; px++) {
const dx = px - c;
const dy = py - c;
const dist = Math.sqrt(dx * dx + dy * dy);
const nd = Math.min(dist / c, 1);
const strength = nd * nd * nd;
const ux = dist > 0 ? dx / dist : 0;
const uy = dist > 0 ? dy / dist : 0;
const i = (py * size + px) * 4;
img.data[i] = Math.round(255 * (0.5 - 0.5 * ux * strength));
img.data[i + 1] = Math.round(255 * (0.5 - 0.5 * uy * strength));
img.data[i + 2] = 128;
img.data[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
const url = canvas.toDataURL();
const raf = requestAnimationFrame(() => setMapUrl(url));
return () => cancelAnimationFrame(raf);
}, [size]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver(() => {
setDims({ width: el.offsetWidth, height: el.offsetHeight });
// mirror the wrapper's surface so the lens magnifies bg + layout, not just children
const cs = getComputedStyle(el);
setCopyStyle({
backgroundColor: cs.backgroundColor,
backgroundImage: cs.backgroundImage,
backgroundSize: cs.backgroundSize,
backgroundPosition: cs.backgroundPosition,
padding: cs.padding,
boxSizing: "border-box",
});
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const lensX = useTransform(sx, (v) => v - size / 2);
const lensY = useTransform(sy, (v) => v - size / 2);
// light comes from top-left, so the glass casts its shadow down-right
const shadowX = useTransform(sx, (v) => v - size / 2 + 8);
const shadowY = useTransform(sy, (v) => v - size / 2 + 12);
// place the magnified copy so the point under the cursor stays centered in the lens
const copyX = useTransform(sx, (v) => size / 2 - magnification * v);
const copyY = useTransform(sy, (v) => size / 2 - magnification * v);
// chromatic aberration: each channel refracts at a different strength
const scaleR = distortion * (1 + 0.5 * aberration);
const scaleG = distortion;
const scaleB = distortion * (1 - 0.5 * aberration);
const isolate = (channel: 0 | 1 | 2) =>
[0, 1, 2, 3]
.map((row) => {
const r = [0, 0, 0, 0, 0];
if (row === 3) r[3] = 1;
else if (row === channel) r[channel] = 1;
return r.join(" ");
})
.join(" ");
return (
<div
ref={ref}
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
x.set(e.clientX - rect.left);
y.set(e.clientY - rect.top);
presence.set(1);
}}
onMouseLeave={() => presence.set(0)}
className={`relative overflow-hidden bg-[#0c0d12] [cursor:none] ${className}`}
>
{children}
<svg className="absolute h-0 w-0" aria-hidden="true">
<defs>
{mapUrl && (
<filter
id={filterId}
x="0"
y="0"
width={size}
height={size}
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feImage
href={mapUrl}
x="0"
y="0"
width={size}
height={size}
result="map"
/>
<feColorMatrix
in="SourceGraphic"
type="matrix"
values={isolate(0)}
result="cr"
/>
<feDisplacementMap
in="cr"
in2="map"
scale={scaleR}
xChannelSelector="R"
yChannelSelector="G"
result="dr"
/>
<feColorMatrix
in="SourceGraphic"
type="matrix"
values={isolate(1)}
result="cg"
/>
<feDisplacementMap
in="cg"
in2="map"
scale={scaleG}
xChannelSelector="R"
yChannelSelector="G"
result="dg"
/>
<feColorMatrix
in="SourceGraphic"
type="matrix"
values={isolate(2)}
result="cb"
/>
<feDisplacementMap
in="cb"
in2="map"
scale={scaleB}
xChannelSelector="R"
yChannelSelector="G"
result="db"
/>
<feComposite
in="dr"
in2="dg"
operator="arithmetic"
k1="0"
k2="1"
k3="1"
k4="0"
result="drg"
/>
<feComposite
in="drg"
in2="db"
operator="arithmetic"
k1="0"
k2="1"
k3="1"
k4="0"
/>
</filter>
)}
</defs>
</svg>
<motion.div
aria-hidden="true"
style={{ x: shadowX, y: shadowY, scale, width: size, height: size }}
className="pointer-events-none absolute left-0 top-0 z-10 rounded-full bg-black/45 blur-xl"
/>
<motion.div
aria-hidden="true"
style={{ x: lensX, y: lensY, scale, width: size, height: size }}
className="pointer-events-none absolute left-0 top-0 z-20 overflow-hidden rounded-full"
>
<div
className="absolute inset-0"
style={{ filter: mapUrl ? `url(#${filterId})` : undefined }}
>
<motion.div
style={{
...copyStyle,
x: copyX,
y: copyY,
scale: magnification,
width: dims.width,
height: dims.height,
transformOrigin: "0 0",
}}
className="absolute left-0 top-0"
>
{children}
</motion.div>
</div>
<div
className="absolute inset-0 rounded-full"
style={{
background:
"radial-gradient(ellipse 45% 30% at 32% 24%, rgba(255,255,255,0.35), transparent 70%)",
}}
/>
<div
className="absolute inset-0 rounded-full"
style={{
boxShadow:
"inset 0 0 0 1px rgba(255,255,255,0.25), inset 2px 4px 10px rgba(255,255,255,0.18), inset -3px -5px 14px rgba(0,0,0,0.35)",
}}
/>
</motion.div>
</div>
);
}