Cursor Lens Magnifier
A glass lens that springs after the pointer and magnifies a live copy of the content beneath it, with a refraction rim, chromatic edge and soft shadow. Press and hold on touch, arrow keys on a keyboard. No dependencies.
DOM + CSS0 deps1 file · 17 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
export type CursorLensMagnifierProps = {
children: ReactNode;
/** Magnification, 1 to 8. */
zoom?: number;
/** Lens diameter in px. */
size?: number;
/** Invert everything inside the glass, so the lens reads as a filter. */
invert?: boolean;
/** Fill behind the magnified copy. Default: read from the nearest opaque ancestor. */
background?: string;
/** [x, y] as 0-1 fractions: park an idling lens here until the pointer takes over. */
rest?: readonly [number, number];
/** Let the mouse wheel change the magnification while the lens is out. */
wheelZoom?: boolean;
/** Show the small magnification readout under the lens (default off). */
showZoom?: boolean;
/** Make the region focusable (default off): arrows move the lens, + and - zoom, Escape hides. */
keyboard?: boolean;
/** Accessible name of the magnifiable region (when keyboard is on). */
label?: string;
className?: string;
style?: CSSProperties;
};
type Sim = {
x: number; y: number; vx: number; vy: number;
s: number; vs: number;
z: number; vz: number;
tx: number; ty: number; zt: number;
active: boolean;
src: "pointer" | "touch" | "key" | "rest";
touchOn: boolean;
reduced: boolean;
copyOn: boolean;
kx: number; ky: number; hasK: boolean;
ox: number; oy: number;
};
const MIN_ZOOM = 1;
const MAX_ZOOM = 8;
const HOLD_MS = 380;
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
const OPAQUE = /^rgba?\(\s*\d+[\s,]+\d+[\s,]+\d+\s*(?:[,/]\s*(?:1|0?\.[1-9]\d*|[1-9]\d*%)\s*)?\)$/i;
const DRAFT = "rgba(0, 0, 0, 0)";
function findBackground(el: HTMLElement | null): string {
let n: HTMLElement | null = el;
while (n) {
const c = getComputedStyle(n).backgroundColor;
if (c && c !== DRAFT && c !== "transparent" && OPAQUE.test(c)) return c;
n = n.parentElement;
}
return "#ffffff";
}
export function CursorLensMagnifier({
children,
zoom = 2,
size = 104,
invert = false,
background,
rest,
wheelZoom = false,
showZoom = false,
keyboard = false,
label = "Magnifiable content",
className,
style,
}: CursorLensMagnifierProps) {
const rootRef = useRef<HTMLDivElement>(null);
const groupRef = useRef<HTMLDivElement>(null);
const lensRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const badgeRef = useRef<HTMLSpanElement>(null);
const wakeRef = useRef<() => void>(() => {});
const holdRef = useRef(0);
const sim = useRef<Sim>({
x: 0, y: 0, vx: 0, vy: 0, s: 0, vs: 0,
z: clamp(zoom, MIN_ZOOM, MAX_ZOOM), vz: 0,
tx: 0, ty: 0, zt: clamp(zoom, MIN_ZOOM, MAX_ZOOM),
active: false, src: "pointer", touchOn: false, reduced: false, copyOn: false,
kx: 0, ky: 0, hasK: false, ox: 0, oy: 0,
});
const cfg = useRef({ size, rest, background, wheelZoom });
const [mounted, setMounted] = useState(false);
const showCopy = mounted || !!rest;
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const descId = "clm" + uid;
const restKey = rest ? rest[0] + "," + rest[1] : "";
useEffect(() => {
cfg.current = { size, rest, background, wheelZoom };
});
useEffect(() => {
sim.current.zt = clamp(zoom, MIN_ZOOM, MAX_ZOOM);
wakeRef.current();
}, [zoom]);
useEffect(() => {
groupRef.current?.toggleAttribute("inert", true);
}, [showCopy]);
/* One loop, DOM writes only: springs for position, presence and zoom. */
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const S = sim.current;
let raf = 0;
let last = 0;
let running = false;
let visible = true;
let clock = 0;
let badgeText = "";
let boxW = 0;
let boxH = 0;
let lastGroup: HTMLDivElement | null = null;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const onMq = () => { S.reduced = mq.matches; wake(); };
S.reduced = mq.matches;
mq.addEventListener("change", onMq);
const frame = (ts: number) => {
raf = 0;
const dt = Math.min(0.05, Math.max(0.001, (ts - last) / 1000));
last = ts;
const w = root.offsetWidth;
const h = root.offsetHeight;
const r = cfg.current.rest;
const sz = cfg.current.size;
let drifting = false;
if (S.active && S.src === "rest" && r) {
clock += dt;
const amp = S.reduced ? 0 : 0.045;
S.tx = clamp((r[0] + Math.sin(clock * 0.7) * amp) * w, 0, w);
S.ty = clamp((r[1] + Math.cos(clock * 0.53) * amp * 0.8) * h, 0, h);
drifting = !S.reduced;
}
const st = S.active ? 1 : 0;
if (S.reduced) {
S.x = S.tx; S.y = S.ty; S.s = st; S.z = S.zt;
S.vx = S.vy = S.vs = S.vz = 0;
} else {
const n = Math.max(1, Math.ceil(dt / (1 / 120)));
const hh = dt / n;
const cs = S.active ? 21 : 30;
for (let i = 0; i < n; i++) {
S.vx += (380 * (S.tx - S.x) - 34 * S.vx) * hh; S.x += S.vx * hh;
S.vy += (380 * (S.ty - S.y) - 34 * S.vy) * hh; S.y += S.vy * hh;
S.vs += (240 * (st - S.s) - cs * S.vs) * hh; S.s += S.vs * hh;
S.vz += (200 * (S.zt - S.z) - 24 * S.vz) * hh; S.z += S.vz * hh;
}
}
const group = groupRef.current;
const inner = innerRef.current;
let ready = false;
if (group && inner) {
ready = true;
if (group !== lastGroup) { lastGroup = group; boxW = 0; boxH = 0; badgeText = ""; }
const sc = Math.max(0, S.s);
const z = Math.max(1, S.z);
group.style.visibility = sc > 0.01 ? "visible" : "hidden";
group.style.opacity = String(Math.min(1, sc * 1.6));
group.style.transform = "translate3d(" + (S.x - sz / 2).toFixed(2) + "px," + (S.y - sz / 2).toFixed(2) + "px,0) scale(" + sc.toFixed(4) + ")";
if (w !== boxW || h !== boxH) {
boxW = w; boxH = h;
inner.style.width = w + "px";
inner.style.height = h + "px";
}
inner.style.transform = "translate3d(" + (sz / 2 - S.x * z).toFixed(2) + "px," + (sz / 2 - S.y * z).toFixed(2) + "px,0) scale(" + z.toFixed(4) + ")";
group.style.setProperty("--clm-dx", (clamp(S.x / (w || 1) - 0.5, -0.5, 0.5) * 6).toFixed(2) + "%");
group.style.setProperty("--clm-dy", (clamp(S.y / (h || 1) - 0.5, -0.5, 0.5) * 6).toFixed(2) + "%");
const bt = z.toFixed(1) + "×";
if (badgeRef.current && bt !== badgeText) { badgeText = bt; badgeRef.current.textContent = bt; }
}
const rest0 =
Math.abs(S.x - S.tx) < 0.05 && Math.abs(S.y - S.ty) < 0.05 && Math.abs(S.s - st) < 0.002 &&
Math.abs(S.z - S.zt) < 0.002 && Math.abs(S.vx) + Math.abs(S.vy) + Math.abs(S.vs) + Math.abs(S.vz) < 0.01;
if (!S.active && rest0 && ready && S.copyOn) { S.copyOn = false; setMounted(false); }
if ((!rest0 || drifting || (!ready && (S.active || S.copyOn))) && visible && !document.hidden) {
raf = requestAnimationFrame(frame);
} else running = false;
};
function wake() {
if (running && raf) return;
if (!visible || document.hidden) return;
running = true;
last = performance.now();
raf = requestAnimationFrame(frame);
}
wakeRef.current = wake;
const io = new IntersectionObserver(([e]) => { visible = !!e?.isIntersecting; if (visible) wake(); }, { rootMargin: "200px" });
io.observe(root);
const onVis = () => { if (!document.hidden) wake(); };
document.addEventListener("visibilitychange", onVis);
const onTouchMove = (e: TouchEvent) => { if (S.touchOn && e.cancelable) e.preventDefault(); };
const onWheel = (e: WheelEvent) => {
if (!cfg.current.wheelZoom || !S.active || S.src !== "pointer") return;
e.preventDefault();
S.zt = clamp(S.zt - e.deltaY * 0.004, MIN_ZOOM, MAX_ZOOM);
wake();
};
root.addEventListener("touchmove", onTouchMove, { passive: false });
root.addEventListener("wheel", onWheel, { passive: false });
return () => {
cancelAnimationFrame(raf);
running = false;
mq.removeEventListener("change", onMq);
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
root.removeEventListener("touchmove", onTouchMove);
root.removeEventListener("wheel", onWheel);
window.clearTimeout(holdRef.current);
};
}, []);
/* Parked lens: show it at the rest position while nobody is driving it. */
useEffect(() => {
const S = sim.current;
const root = rootRef.current;
if (!root || !restKey) return;
if (S.active && S.src !== "rest") return;
const [fx, fy] = restKey.split(",").map(Number);
S.active = true; S.src = "rest"; S.copyOn = true;
S.tx = (fx ?? 0.5) * root.offsetWidth; S.ty = (fy ?? 0.5) * root.offsetHeight;
if (S.s < 0.02) { S.x = S.tx; S.y = S.ty; }
if (lensRef.current) lensRef.current.style.background = cfg.current.background ?? findBackground(root);
wakeRef.current();
}, [restKey]);
function local(e: { clientX: number; clientY: number }) {
const el = rootRef.current;
if (!el) return null;
const r = el.getBoundingClientRect();
const sx = r.width ? el.offsetWidth / r.width : 1;
const sy = r.height ? el.offsetHeight / r.height : 1;
return { x: (e.clientX - r.left) * sx, y: (e.clientY - r.top) * sy, w: el.offsetWidth, h: el.offsetHeight };
}
function activate(src: Sim["src"]) {
const S = sim.current;
const root = rootRef.current;
if (!root) return;
if (!S.active || S.src === "rest") {
if (lensRef.current) lensRef.current.style.background = cfg.current.background ?? findBackground(root);
}
if (S.s < 0.02) { S.x = S.tx; S.y = S.ty; }
S.active = true;
S.src = src;
S.copyOn = true;
setMounted(true);
}
function release() {
const S = sim.current;
const root = rootRef.current;
window.clearTimeout(holdRef.current);
S.touchOn = false;
if (root) root.style.cursor = "";
if (cfg.current.rest) {
S.src = "rest";
S.active = true;
} else S.active = false;
wakeRef.current();
}
function aim(p: { x: number; y: number; w: number; h: number }, touch: boolean) {
const S = sim.current;
const sz = cfg.current.size;
let ty = p.y;
if (touch) {
const lift = sz / 2 + 36;
ty = p.y - lift < sz * 0.3 ? p.y + lift : p.y - lift;
}
S.tx = clamp(p.x, 0, p.w);
S.ty = clamp(ty, 0, p.h);
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
const S = sim.current;
const p = local(e);
if (!p) return;
if (e.pointerType === "touch") {
if (!S.touchOn) {
const dx = p.x - S.ox, dy = p.y - S.oy;
if (dx * dx + dy * dy > 100) window.clearTimeout(holdRef.current);
return;
}
aim(p, true);
wakeRef.current();
return;
}
aim(p, false);
if (!S.active || S.src !== "pointer") activate("pointer");
if (rootRef.current) rootRef.current.style.cursor = "none";
wakeRef.current();
}
function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
if (e.pointerType !== "touch") return;
const p = local(e);
if (!p) return;
const S = sim.current;
S.ox = p.x; S.oy = p.y;
window.clearTimeout(holdRef.current);
holdRef.current = window.setTimeout(() => {
S.touchOn = true;
aim(p, true);
activate("touch");
if (typeof navigator !== "undefined" && "vibrate" in navigator) navigator.vibrate(8);
wakeRef.current();
}, HOLD_MS);
}
function onPointerEnd(e: React.PointerEvent<HTMLDivElement>) {
if (e.pointerType === "touch" || sim.current.src === "pointer") release();
}
function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (!keyboard || e.target !== e.currentTarget) return;
const S = sim.current;
const root = e.currentTarget;
const w = root.offsetWidth, h = root.offsetHeight;
if (!S.hasK) { S.kx = w / 2; S.ky = h / 2; S.hasK = true; }
const step = e.shiftKey ? 80 : 20;
let used = true;
if (e.key === "ArrowLeft") S.kx -= step;
else if (e.key === "ArrowRight") S.kx += step;
else if (e.key === "ArrowUp") S.ky -= step;
else if (e.key === "ArrowDown") S.ky += step;
else if (e.key === "+" || e.key === "=") S.zt = clamp(S.zt + 0.25, MIN_ZOOM, MAX_ZOOM);
else if (e.key === "-" || e.key === "_") S.zt = clamp(S.zt - 0.25, MIN_ZOOM, MAX_ZOOM);
else if (e.key === "Escape" && S.active && S.src === "key") { release(); return; }
else used = false;
if (!used) return;
e.preventDefault();
S.kx = clamp(S.kx, 0, w); S.ky = clamp(S.ky, 0, h);
S.tx = S.kx; S.ty = S.ky;
if (!S.active || S.src !== "key") activate("key");
wakeRef.current();
}
function onFocus(e: React.FocusEvent<HTMLDivElement>) {
if (!keyboard || e.target !== e.currentTarget) return;
if (!e.currentTarget.matches(":focus-visible")) return;
const S = sim.current;
const root = e.currentTarget;
if (!S.hasK) { S.kx = root.offsetWidth / 2; S.ky = root.offsetHeight / 2; S.hasK = true; }
S.tx = S.kx; S.ty = S.ky;
activate("key");
wakeRef.current();
}
function onBlur(e: React.FocusEvent<HTMLDivElement>) {
if (e.target === e.currentTarget && sim.current.src === "key") release();
}
const lensBg = background ?? "#ffffff";
const ring: CSSProperties = {
background: "conic-gradient(from 200deg, rgba(255,255,255,0.8), rgba(255,255,255,0.14) 26%, rgba(255,255,255,0.55) 52%, rgba(255,255,255,0.1) 76%, rgba(255,255,255,0.8))",
WebkitMask: "radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2px))",
mask: "radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2px))",
};
return (
<div
ref={rootRef}
onPointerMove={onPointerMove}
onPointerDown={onPointerDown}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
onPointerLeave={onPointerEnd}
onContextMenu={(e) => { if (sim.current.touchOn) e.preventDefault(); }}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
tabIndex={keyboard ? 0 : undefined}
role={keyboard ? "group" : undefined}
aria-label={keyboard ? label : undefined}
aria-describedby={keyboard ? descId : undefined}
className={
"relative overflow-hidden [@media(pointer:coarse)]:select-none [@media(pointer:coarse)]:[-webkit-touch-callout:none] focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-[#5b9dff]" +
(className ? " " + className : "")
}
style={style}
>
{children}
{keyboard && (
<span id={descId} className="sr-only">
Magnifier. Arrow keys move the lens, plus and minus change the magnification, Escape hides it.
</span>
)}
{showCopy && (
<div
ref={groupRef}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0"
style={{ width: size, height: size, visibility: "hidden", opacity: 0, willChange: "transform, opacity" }}
>
<div
ref={lensRef}
className="absolute inset-0 overflow-hidden rounded-full"
style={{
background: lensBg,
boxShadow: "0 0 0 1px rgba(0,0,0,0.3), 0 24px 40px -14px rgba(0,0,0,0.55), 0 8px 16px -6px rgba(0,0,0,0.3)",
isolation: "isolate",
}}
>
{/* A second render of the same children, scaled about the lens centre so the magnified point stays under it. */}
<div
ref={innerRef}
className="absolute left-0 top-0 origin-top-left"
style={{ filter: invert ? "invert(1)" : undefined }}
>
{children}
</div>
<div className="absolute inset-0 rounded-full" style={{ background: "radial-gradient(circle closest-side, rgba(255,255,255,0.04) 0%, rgba(0,0,0,0) 56%, rgba(150,185,255,0.12) 86%, rgba(0,0,0,0.2) 100%)" }} />
<div className="absolute inset-0 rounded-full" style={{ background: "radial-gradient(circle farthest-side at calc(50% + var(--clm-dx, 0%)) calc(50% + var(--clm-dy, 0%)), rgba(255,60,60,0) 89%, rgba(255,70,70,0.5) 97%, rgba(255,70,70,0) 100%)" }} />
<div className="absolute inset-0 rounded-full" style={{ background: "radial-gradient(circle farthest-side at calc(50% - var(--clm-dx, 0%)) calc(50% - var(--clm-dy, 0%)), rgba(60,190,255,0) 89%, rgba(70,190,255,0.5) 97%, rgba(70,190,255,0) 100%)" }} />
<div className="absolute inset-0 rounded-full" style={{ background: "radial-gradient(ellipse 60% 36% at 34% 14%, rgba(255,255,255,0.26), rgba(255,255,255,0) 72%), radial-gradient(ellipse 48% 26% at 68% 93%, rgba(255,255,255,0.16), rgba(255,255,255,0) 72%)" }} />
<div className="absolute inset-0 rounded-full" style={ring} />
</div>
{showZoom && (
<span
ref={badgeRef}
className="absolute left-1/2 whitespace-nowrap rounded-full px-2 py-0.5 text-[10px] leading-4 tabular-nums"
style={{
top: "calc(100% + 10px)",
transform: "translateX(-50%)",
background: "rgba(10,12,16,0.86)",
color: "#fff",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.16), 0 6px 14px -6px rgba(0,0,0,0.5)",
}}
>
</span>
)}
</div>
)}
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
The lens holds a second render of the same children, scaled about the lens centre, so the magnified layer is real DOM: live text, sharp at any zoom, and never out of step with the original. Translating by half the lens size minus the lens position times the zoom, before scaling from the top-left corner, is what keeps the magnified point under the glass. The lens now follows on a tuned spring, so it trails the pointer by a hair instead of snapping, and it scales in and out with a slight overshoot. The glass is painted in layers: an edge vignette that darkens like bent light, a red and a cyan fringe whose offset shifts with where the lens sits in the region (dispersion grows toward the edges, as in a real lens), a specular crescent, and a conic rim highlight, with a soft double shadow and an optional magnification readout (showZoom). The fill behind the copy is read from the nearest opaque ancestor, so the lens works on light and dark pages, or set it with the background prop. A single requestAnimationFrame loop writes straight to the DOM and sleeps when the lens is at rest, off-screen or in a hidden tab. Set rest to park an idling lens that drifts gently until the pointer takes over; turn on wheelZoom to scroll the magnification between 1x and 8x. The cost is unchanged: everything inside renders twice, so use it over a bounded region such as a map, a specimen or a diagram, and avoid wrapping controls.
Curator’s note
Cursor Lens Magnifier, rebuilt in October: the same live-DOM idea, now a glass lens with a spring, a refracting rim and a way in for touch and keyboard.

