"use client";
import {
memo,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useAnimationLoop, type Metrics } from "@/hooks/use-animation-loop";
export interface LensProps {
children?: ReactNode;
size?: number;
refraction?: number;
edgeThickness?: number;
blur?: number;
chromatic?: number;
magnify?: number;
shimmer?: number;
friction?: number;
bounded?: boolean;
rimColor?: string;
paused?: boolean;
reducedMotion?: boolean;
className?: string;
}
const smoothstep = (a: number, b: number, x: number) => {
const t = Math.min(1, Math.max(0, (x - a) / (b - a || 1e-6)));
return t * t * (3 - 2 * t);
};
const PAD_RATIO = 0.35;
const MAP_RES = 320;
const INNER = 1 / (1 + 2 * PAD_RATIO);
const LENS_TAG = "data-sg-lens";
function flowPositions(sticky: HTMLElement[]): Array<[number, number]> {
const restore = sticky.map((el) => el.style.position);
for (const el of sticky) el.style.position = "static";
const flow = sticky.map((el) => {
const r = el.getBoundingClientRect();
return [r.left + window.scrollX, r.top + window.scrollY] as [number, number];
});
sticky.forEach((el, i) => {
el.style.position = restore[i];
});
return flow;
}
function snapshotPage(host: HTMLDivElement) {
const src = Array.from(document.body.querySelectorAll<HTMLElement>("*"));
const root = document.body.cloneNode(true) as HTMLElement;
const dst = Array.from(root.querySelectorAll<HTMLElement>("*"));
const scrolled: Array<[HTMLElement, number, number]> = [];
const positions = src.map((el) => getComputedStyle(el).position);
const stickyEls = src.filter((_, i) => positions[i] === "sticky");
const stickyFlow = new Map<HTMLElement, [number, number]>();
flowPositions(stickyEls).forEach((p, i) => stickyFlow.set(stickyEls[i], p));
for (let i = 0; i < src.length; i++) {
const copy = dst[i];
if (!copy) continue;
const from = src[i];
if (from.scrollTop || from.scrollLeft) {
scrolled.push([copy, from.scrollTop, from.scrollLeft]);
}
const pos = positions[i];
if (pos !== "fixed" && pos !== "sticky") continue;
const r = from.getBoundingClientRect();
if (pos === "sticky") {
const flow = stickyFlow.get(from);
if (flow) {
copy.style.position = "relative";
copy.style.left = `${r.left + window.scrollX - flow[0]}px`;
copy.style.top = `${r.top + window.scrollY - flow[1]}px`;
}
} else {
copy.style.position = "absolute";
copy.style.left = `${r.left + window.scrollX}px`;
copy.style.top = `${r.top + window.scrollY}px`;
copy.style.width = `${r.width}px`;
copy.style.height = `${r.height}px`;
copy.style.right = "auto";
copy.style.bottom = "auto";
copy.style.margin = "0";
}
}
for (const lens of Array.from(root.querySelectorAll(`[${LENS_TAG}]`))) {
lens.remove();
}
const holder = document.createElement("div");
holder.style.background = getComputedStyle(document.body).backgroundColor;
while (root.firstChild) holder.appendChild(root.firstChild);
host.style.width = `${document.documentElement.scrollWidth}px`;
host.style.height = `${document.documentElement.scrollHeight}px`;
host.replaceChildren(holder);
for (const [el, top, left] of scrolled) {
el.scrollTop = top;
el.scrollLeft = left;
}
}
function buildDisplacementMap(edge: number, inner: number): string {
const canvas = document.createElement("canvas");
canvas.width = MAP_RES;
canvas.height = MAP_RES;
const ctx = canvas.getContext("2d");
if (!ctx) return "";
const image = ctx.createImageData(MAP_RES, MAP_RES);
const data = image.data;
const half = MAP_RES / 2;
for (let y = 0; y < MAP_RES; y++) {
for (let x = 0; x < MAP_RES; x++) {
const i = (y * MAP_RES + x) * 4;
const dx = (x - half) / half;
const dy = (y - half) / half;
const r = Math.hypot(dx, dy);
const rr = r / inner;
let px = 0;
let py = 0;
if (rr <= 1 && rr > 1e-4) {
const bevel = smoothstep(1 - Math.max(edge, 0.02), 1, rr);
const mag = bevel * bevel;
px = (dx / r) * mag;
py = (dy / r) * mag;
}
data[i] = Math.max(0, Math.min(255, 128 + px * 127));
data[i + 1] = Math.max(0, Math.min(255, 128 + py * 127));
data[i + 2] = 128;
data[i + 3] = 255;
}
}
ctx.putImageData(image, 0, 0);
return canvas.toDataURL();
}
const Lens = memo(
({
children,
size = 45,
refraction = 0.55,
edgeThickness = 0.3,
blur = 0,
chromatic = 0.22,
magnify = 1.6,
shimmer = 0.35,
friction = 0.9,
bounded = true,
rimColor = "#e9e6ff",
paused = false,
reducedMotion = false,
className,
}: LensProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const lensRef = useRef<HTMLDivElement>(null);
const dupRef = useRef<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const rimRef = useRef<HTMLDivElement>(null);
const filterId = `sg-lens-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
const [map, setMap] = useState("");
const box = useRef({ w: 0, h: 0 });
const state = useRef({
x: 0, y: 0, vx: 0, vy: 0, phase: 0,
dragging: false, pointerId: -1, grabX: 0, grabY: 0, lastT: 0, placed: false,
lastSX: 0, lastSY: 0,
});
const diaRef = useRef(0);
const [dia, setDia] = useState(0);
const capture = useRef<HTMLDivElement | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const live = useRef({ size, friction, bounded, shimmer, reducedMotion });
live.current = { size, friction, bounded, shimmer, reducedMotion };
const sizeDuplicate = useCallback(() => {
const dup = dupRef.current;
if (!dup || box.current.w === 0) return;
dup.style.width = `${box.current.w}px`;
dup.style.height = `${box.current.h}px`;
}, []);
const loop = useAnimationLoop({
target: containerRef,
halted: paused,
onResize: (metrics: Metrics) => {
box.current = { w: metrics.width, h: metrics.height };
const next = Math.round(
(live.current.size / 100) * Math.min(metrics.width, metrics.height),
);
if (next !== diaRef.current) {
diaRef.current = next;
setDia(next);
}
const s = state.current;
if (!s.placed && metrics.width > 0 && next > 0) {
s.x = metrics.width / 2 - next / 2;
s.y = metrics.height / 2 - next / 2;
s.placed = true;
}
sizeDuplicate();
},
onFrame: ({ dt }) => {
const l = live.current;
const s = state.current;
const d = diaRef.current;
if (!s.dragging) {
s.x += s.vx * dt;
s.y += s.vy * dt;
const decay = Math.pow(l.friction, dt * 60);
s.vx *= decay;
s.vy *= decay;
}
let minX = 0;
let minY = 0;
let maxX = Math.max(0, box.current.w - d);
let maxY = Math.max(0, box.current.h - d);
const el = containerRef.current;
const r = !l.bounded && el ? el.getBoundingClientRect() : null;
if (r) {
minX = -r.left;
minY = -r.top;
maxX = Math.max(minX, window.innerWidth - d - r.left);
maxY = Math.max(minY, window.innerHeight - d - r.top);
}
if (s.x < minX) { s.x = minX; s.vx = Math.abs(s.vx) * 0.4; }
if (s.y < minY) { s.y = minY; s.vy = Math.abs(s.vy) * 0.4; }
if (s.x > maxX) { s.x = maxX; s.vx = -Math.abs(s.vx) * 0.4; }
if (s.y > maxY) { s.y = maxY; s.vy = -Math.abs(s.vy) * 0.4; }
const lens = lensRef.current;
if (lens) {
const tx = r ? r.left + s.x : s.x;
const ty = r ? r.top + s.y : s.y;
lens.style.transform = `translate3d(${tx.toFixed(2)}px, ${ty.toFixed(2)}px, 0)`;
}
const p = Math.round(d * PAD_RATIO);
const dup = dupRef.current;
if (dup) {
dup.style.transform = `translate3d(${(p - s.x).toFixed(2)}px, ${(p - s.y).toFixed(2)}px, 0)`;
}
const page = pageRef.current;
if (page && r) {
const px = p - (r.left + s.x) - window.scrollX;
const py = p - (r.top + s.y) - window.scrollY;
page.style.transform = `translate3d(${px.toFixed(2)}px, ${py.toFixed(2)}px, 0)`;
}
if (!l.reducedMotion) s.phase += dt * l.shimmer;
const rim = rimRef.current;
if (rim) {
rim.style.transform = `rotate(${((s.phase * 90) % 360).toFixed(2)}deg)`;
}
},
deps: [],
});
useEffect(() => {
setMap(buildDisplacementMap(edgeThickness, INNER));
}, [edgeThickness]);
useEffect(() => {
const next = Math.round(
(size / 100) * Math.min(box.current.w, box.current.h),
);
if (next !== diaRef.current) {
diaRef.current = next;
setDia(next);
}
}, [size]);
useEffect(() => {
const s = state.current;
s.lastSX = window.scrollX;
s.lastSY = window.scrollY;
const onScroll = () => {
const dx = window.scrollX - s.lastSX;
const dy = window.scrollY - s.lastSY;
s.lastSX = window.scrollX;
s.lastSY = window.scrollY;
if (live.current.bounded || (dx === 0 && dy === 0)) return;
s.x += dx;
s.y += dy;
loop.paint();
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [loop]);
useLayoutEffect(() => {
sizeDuplicate();
}, [dia, bounded, sizeDuplicate]);
useEffect(() => {
loop.paint();
}, [dia, refraction, blur, chromatic, magnify, shimmer, friction, bounded, rimColor, map, loop]);
useEffect(() => {
if (bounded || !mounted) return;
let settle = 0;
const take = () => {
if (state.current.dragging) return;
if (pageRef.current) snapshotPage(pageRef.current);
loop.paint();
};
const later = () => {
window.clearTimeout(settle);
settle = window.setTimeout(take, 150);
};
later();
window.addEventListener("resize", later);
window.addEventListener("scroll", later, { passive: true });
return () => {
window.clearTimeout(settle);
window.removeEventListener("resize", later);
window.removeEventListener("scroll", later);
};
}, [bounded, mounted, loop]);
const rectOf = () => containerRef.current?.getBoundingClientRect();
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const s = state.current;
const r = rectOf();
if (!r || s.dragging) return;
if (pageRef.current) snapshotPage(pageRef.current);
s.dragging = true;
s.pointerId = e.pointerId;
s.grabX = e.clientX - r.left - s.x;
s.grabY = e.clientY - r.top - s.y;
s.vx = 0;
s.vy = 0;
s.lastT = e.timeStamp;
capture.current = e.currentTarget;
e.currentTarget.setPointerCapture(e.pointerId);
loop.start();
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const s = state.current;
if (!s.dragging || e.pointerId !== s.pointerId) return;
if (capture.current !== e.currentTarget) return;
const r = rectOf();
if (!r) return;
const nx = e.clientX - r.left - s.grabX;
const ny = e.clientY - r.top - s.grabY;
const dt = (e.timeStamp - s.lastT) / 1000;
if (dt > 0.001) {
s.vx = (nx - s.x) / dt;
s.vy = (ny - s.y) / dt;
s.lastT = e.timeStamp;
}
s.x = nx;
s.y = ny;
loop.start();
};
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
const s = state.current;
const held = capture.current;
if (held && s.pointerId !== -1 && held.hasPointerCapture(s.pointerId)) {
held.releasePointerCapture(s.pointerId);
}
capture.current = null;
s.dragging = false;
s.pointerId = -1;
loop.start();
};
const drag = {
onPointerDown,
onPointerMove,
onPointerUp: endDrag,
onPointerCancel: endDrag,
};
const scale = refraction * dia * 0.32;
const rgb = chromatic * dia * 0.09;
const pad = Math.round(dia * PAD_RATIO);
const host = dia + pad * 2;
const blurTerm = blur > 0 ? `blur(${blur}px)` : "";
const glass = map
? `url(#${filterId})${blurTerm ? ` ${blurTerm}` : ""}`
: blurTerm;
const escaped = !bounded && mounted;
const disc = (
<div
ref={lensRef}
{...drag}
{...{ [LENS_TAG]: "" }}
className="cursor-grab touch-none rounded-full select-none active:cursor-grabbing"
style={{
position: escaped ? "fixed" : "absolute",
top: 0,
left: 0,
zIndex: escaped ? 9999 : undefined,
width: dia,
height: dia,
boxShadow:
"0 18px 46px rgb(0 0 0 / 0.45), inset 0 1px 1px rgb(255 255 255 / 0.35)",
}}
>
<div className="absolute inset-0 overflow-hidden rounded-full">
<div
className="absolute"
style={{
inset: -pad,
filter: glass || undefined,
}}
>
<div
className="absolute inset-0"
style={{
transform: `scale(${magnify})`,
transformOrigin: "50% 50%",
}}
>
<div
ref={dupRef}
aria-hidden
className="absolute top-0 left-0"
style={{ visibility: escaped ? "hidden" : undefined }}
>
{children}
</div>
<div
ref={pageRef}
aria-hidden
className="absolute top-0 left-0 select-none"
style={{
visibility: escaped ? undefined : "hidden",
pointerEvents: "none",
}}
/>
</div>
</div>
</div>
<div
ref={rimRef}
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full"
style={{
background: `conic-gradient(from 0deg, transparent 0deg, ${rimColor} 40deg, transparent 110deg, transparent 200deg, ${rimColor}88 250deg, transparent 320deg)`,
maskImage:
"radial-gradient(circle closest-side, transparent calc(100% - 3px), #000 calc(100% - 2px))",
mixBlendMode: "plus-lighter",
opacity: 0.75,
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full"
style={{
boxShadow: `inset 0 0 0 1px ${rimColor}55, inset 0 8px 18px rgb(255 255 255 / 0.10)`,
}}
/>
</div>
);
return (
<>
<div
ref={containerRef}
className={
className ??
`relative h-full w-full touch-none select-none${bounded ? " overflow-hidden" : ""}`
}
{...drag}
>
<svg aria-hidden className="absolute h-0 w-0" focusable="false">
<defs>
<filter
id={filterId}
colorInterpolationFilters="sRGB"
x="0"
y="0"
width="100%"
height="100%"
>
{map ? (
<feImage
href={map}
result="map"
x="0"
y="0"
width={host}
height={host}
preserveAspectRatio="none"
/>
) : null}
<feDisplacementMap
in="SourceGraphic"
in2="map"
scale={scale + rgb}
xChannelSelector="R"
yChannelSelector="G"
result="dR"
/>
<feDisplacementMap
in="SourceGraphic"
in2="map"
scale={scale}
xChannelSelector="R"
yChannelSelector="G"
result="dG"
/>
<feDisplacementMap
in="SourceGraphic"
in2="map"
scale={Math.max(0, scale - rgb)}
xChannelSelector="R"
yChannelSelector="G"
result="dB"
/>
<feColorMatrix
in="dR"
type="matrix"
values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
result="cR"
/>
<feColorMatrix
in="dG"
type="matrix"
values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0"
result="cG"
/>
<feColorMatrix
in="dB"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0"
result="cB"
/>
<feComposite
in="cR"
in2="cG"
operator="arithmetic"
k1="0"
k2="1"
k3="1"
k4="0"
result="rg"
/>
<feComposite
in="rg"
in2="cB"
operator="arithmetic"
k1="0"
k2="1"
k3="1"
k4="0"
/>
</filter>
</defs>
</svg>
<div className="absolute inset-0">{children}</div>
{escaped ? null : disc}
</div>
{escaped ? createPortal(disc, document.body) : null}
</>
);
},
);
Lens.displayName = "Lens";
export default Lens;