A premium cursor lens effect that bends the page surface under the pointer for agency homepages, portfolios, and launch pages.

Liquid Glass Cursor turns pointer movement into atmosphere: a glass lens bends the page surface.
It belongs on creative homepages, portfolios, campaign pages, and brand-led experiments where the cursor can become part of the visual language. It does not belong on dashboards, checkout, forms, or utility-heavy product screens.
The thing to watch is precision. The cursor layer must stay disposable, disable on coarse pointers, preserve visible focus, and never hide click intent. If removing the effect breaks the page, the cursor has been promoted past its pay grade.
npx hyperiux add liquid-glass-cursorimport LiquidGlassCursor from '@/components/effects/liquid-glass-cursor'
const page = () => {
return (
<>
<LiquidGlassCursor baseSize={30} textHoverSize={130} borderColor="rgba(0,0,0,0.05)" />
</>
)
}
/**
* Example usage — LiquidGlassCursor
*
* 1. Mount the cursor once (usually at page or layout level).
* 2. Mark interactive elements with data-cursor:
* - data-cursor="text" → expands to a circle, shows "CLICK"
* - data-cursor="button" → morphs to a pill around the element, shows "DISCOVER"
*
* Props:
* baseSize — default cursor diameter (px)
* textHoverSize — diameter when hovering data-cursor="text"
* borderColor — inset shadow / border tint
* distortionScale — liquid displacement strength
* blurAmount — frosted blur amount
* brightness — backdrop brightness filter
* maxButtonWidth — max pill width for data-cursor="button"
* maxButtonHeight — max pill height for data-cursor="button"
*
* @example
* <LiquidGlassCursor
* baseSize={30}
* textHoverSize={130}
* borderColor="rgba(0,0,0,0.05)"
* />
* <h1 data-cursor="text">HELLO</h1>
* <button data-cursor="button">Explore</button>
*/
// Built using Hyperiux Vault: https://vault.hyperiux.com
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, } from "motion/react";
const TEXT_SELECTOR = '[data-cursor="text"], p, span, h1, h2, h3, h4, h5, h6, label';
const BUTTON_SELECTOR = '[data-cursor="button"], button, a, [role="button"], input, select, textarea';
export default function LiquidGlassCursor({ children, size = 150, magnification = 1.15, textHoverSize = 130, maxButtonWidth = 260, maxButtonHeight = 110, distortion = 60, aberration = 1, className = "", }) {
const ref = useRef(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({});
const [mapUrl, setMapUrl] = useState("");
const reduceMotion = useReducedMotion();
const mapSize = Math.max(size, textHoverSize, maxButtonWidth, maxButtonHeight);
const x = useMotionValue(0);
const y = useMotionValue(0);
const presence = useMotionValue(0);
const targetW = useMotionValue(size);
const targetH = useMotionValue(size);
const springConfig = reduceMotion
? { stiffness: 2000, damping: 90 }
: { stiffness: 300, damping: 26 };
const shapeSpringConfig = reduceMotion
? { stiffness: 2000, damping: 90 }
: { stiffness: 340, damping: 28 };
const sx = useSpring(x, springConfig);
const sy = useSpring(y, springConfig);
const scale = useSpring(presence, { stiffness: 260, damping: 20 });
const sw = useSpring(targetW, shapeSpringConfig);
const sh = useSpring(targetH, shapeSpringConfig);
const setShapeForTarget = (target) => {
const el = target instanceof Element ? target : null;
if (el?.closest(BUTTON_SELECTOR)) {
targetW.set(maxButtonWidth);
targetH.set(maxButtonHeight);
}
else if (el?.closest(TEXT_SELECTOR)) {
targetW.set(textHoverSize);
targetH.set(textHoverSize);
}
else {
targetW.set(size);
targetH.set(size);
}
};
useEffect(() => {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = mapSize;
const ctx = canvas.getContext("2d");
if (!ctx)
return;
const img = ctx.createImageData(mapSize, mapSize);
const c = mapSize / 2;
for (let py = 0; py < mapSize; py++) {
for (let px = 0; px < mapSize; 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 * mapSize + 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);
}, [mapSize]);
useEffect(() => {
const el = ref.current;
if (!el)
return;
const ro = new ResizeObserver(() => {
setDims({ width: el.offsetWidth, height: el.offsetHeight });
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, sw], ([v, w]) => v - w / 2);
const lensY = useTransform([sy, sh], ([v, h]) => v - h / 2);
const shadowX = useTransform([sx, sw], ([v, w]) => v - w / 2 + 8);
const shadowY = useTransform([sy, sh], ([v, h]) => v - h / 2 + 12);
const copyX = useTransform([sx, sw], ([v, w]) => w / 2 - magnification * v);
const copyY = useTransform([sy, sh], ([v, h]) => h / 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, 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);
}} onMouseOver={(e) => setShapeForTarget(e.target)} onMouseLeave={() => {
presence.set(0);
targetW.set(size);
targetH.set(size);
}} 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="100%" height="100%" colorInterpolationFilters="sRGB">
<feImage href={mapUrl} x="0" y="0" width="100%" height="100%" preserveAspectRatio="none" 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: sw, height: sh }} 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: sw, height: sh }} 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>
{reduceMotion && (<div aria-live="polite" className="pointer-events-none absolute bottom-4 right-4 z-40 w-fit max-w-65 rounded-md bg-black/10 p-3 text-center backdrop-blur-md max-md:hidden">
<h2 className="text-sm leading-none text-black">
The lens keeps tracking.
</h2>
<p className="mt-2 text-xs leading-5 text-black">
Liquid Glass Cursor magnifies and refracts whatever your cursor
passes over. Since the lens is driven entirely by cursor motion,
there's no reduced motion version to fall back on.
</p>
</div>)}
</div>);
}
Use this as cursor-system implementation guidance. Verify the shipped component export, pointer-tracking model, coarse-pointer disablement, z-index behavior, focus safety, cleanup, and reduced-motion handling before relying on exact props, defaults, imports, or installation steps.
Not for dashboards, checkout, forms, dense product UIs, or any flow where precision beats atmosphere.
Use one cursor layer, throttle movement with requestAnimationFrame, avoid full-screen filter effects, and disable Liquid Glass Cursor on coarse pointers.
Disable on coarse pointers and preserve normal touch behavior. Cursor-only reveals must have visible or tap-accessible alternatives.
| Prop | Type | Default | Description |
|---|---|---|---|
size | number | 150 | Diameter of the lens in pixels. |
magnification | number | 1.15 | Zoom level applied to the content copy under the lens. |
textHoverSize | number | 130 | Lens diameter when hovering a data-cursor="text" target. |
maxButtonWidth | number | 260 | Lens width when hovering a data-cursor="button" target. |
maxButtonHeight | number | 110 | Lens height when hovering a data-cursor="button" target. |
distortion | number | 60 | SVG displacement strength for the glass refraction. |
aberration | number | 1 | Chromatic aberration amount — how far the R/G/B channels split at the rim. |
Use it when a high-craft homepage needs a glass lens bending the page surface under the pointer not on utility flows.
Disable it on coarse pointers with CSS and JS pointer queries, then provide native touch behaviour.
Only when the same content is visible or accessible without pointer movement. Cursor-only reveals are not suitable for critical information.
It must preserve native click meaning, visible focus, hover states, and modal layering.
Disable trails, lag, ripples, and pointer-following motion, then return to the native cursor.
Yes. A custom version should define motion rules, visual treatment, performance budget, fallback behavior, source handoff, and implementation notes for the specific page or launch context.
Need a custom effect? Tell us what to create.