Loupe
Magnifier lens that turns any child — image, SVG, or subtree — into an inspectable surface. Spring-smoothed pointer follow or a pinned mode, a machined bezel with a velocity-driven specular arc, focus-blur on the surface behind, and full keyboard walking. Pure transform/clip compositing, zero canvas.
motionfree
"use client";
import {
AnimatePresence,
motion,
useMotionTemplate,
useMotionValue,
useSpring,
useTransform,
useVelocity,
} from "motion/react";
import * as React from "react";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { cn } from "@/lib/utils";
export interface LoupeProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
/** The surface to inspect — an image, an SVG, or any React subtree. */
children: React.ReactNode;
/** Magnification inside the lens. @default 1.6 */
zoom?: number;
/** Lens diameter, in px. @default 176 */
size?: number;
/** Pin the lens at a fixed spot instead of tracking the pointer. @default false */
pinned?: boolean;
/** Lens center as fractions of the surface (0–1) while `pinned`. @default { x: 0.5, y: 0.5 } */
pinnedPosition?: { x: number; y: number };
/** Controlled active state — when set, overrides internal hover/focus. */
active?: boolean;
/** Fires whenever the lens opens or closes (pointer, focus, or keyboard). */
onActiveChange?: (active: boolean) => void;
/** Softly blur + dim the surface outside the lens while it is active. @default true */
focusBlur?: boolean;
/** Blur radius, in px, applied behind an active lens. @default 2 */
focusBlurStrength?: number;
/** Play the fast scale-settle entrance when the lens appears. @default true */
animateEntrance?: boolean;
/** Pixels the lens walks per arrow-key press while focused. @default 24 */
keyboardStep?: number;
/** Rim + specular color of the machined bezel. @default "rgba(255,255,255,0.72)" */
rimColor?: string;
}
const FOLLOW = { stiffness: 520, damping: 42, mass: 0.6 } as const;
function clamp(v: number, lo: number, hi: number) {
return v < lo ? lo : v > hi ? hi : v;
}
/**
* Loupe — a jeweler's magnifier for any child. Hovering summons a circular
* lens that shows the underlying content enlarged; the lens follows the pointer
* with a whisper of spring-smoothing, or sits pinned at a fixed coordinate.
*
* Crucible signature: a machined bezel, not a comedy magnifying glass —
* neutral hue family. A hairline bright rim, a ground-glass edge falloff, and a
* single almost-subliminal specular arc along the upper-left rim that drifts
* with pointer velocity, selling curved glass with no rainbow fringing. While
* active, the surface outside the lens softly blurs to concentrate attention.
* Keyboard: focusing the wrapper opens the lens centered and arrow keys walk
* it in steps. Reduced motion keeps full magnification (it is positional, not a
* flourish) but drops the follow-smoothing, entrance scale, and rim-arc drift —
* the lens simply snaps. Pure transform/clip compositing, zero canvas; no work
* at all while unhovered.
*/
export function Loupe({
children,
className,
zoom = 1.6,
size = 176,
pinned = false,
pinnedPosition = { x: 0.5, y: 0.5 },
active: controlledActive,
onActiveChange,
focusBlur = true,
focusBlurStrength = 2,
animateEntrance = true,
keyboardStep = 24,
rimColor = "rgba(255,255,255,0.72)",
...props
}: LoupeProps) {
const reducedMotion = useReducedMotion();
const wrapperRef = React.useRef<HTMLDivElement>(null);
const r = size / 2;
const [surface, setSurface] = React.useState({ w: 0, h: 0 });
const [internalActive, setInternalActive] = React.useState(false);
const isControlled = controlledActive !== undefined;
const active = pinned || (isControlled ? (controlledActive as boolean) : internalActive);
const px = pinnedPosition.x;
const py = pinnedPosition.y;
// Raw pointer/keyboard center, in px within the surface.
const cx = useMotionValue(0);
const cy = useMotionValue(0);
// Spring-smoothed follow. Under reduced motion we read the raw value directly.
const scx = useSpring(cx, FOLLOW);
const scy = useSpring(cy, FOLLOW);
const posX = reducedMotion ? cx : scx;
const posY = reducedMotion ? cy : scy;
// Lens box (top-left) and the magnified copy's transform derive from the same
// smoothed center, so glass and content never disagree.
const lensLeft = useTransform(posX, (v) => v - r);
const lensTop = useTransform(posY, (v) => v - r);
const innerX = useTransform(posX, (v) => r - v * zoom);
const innerY = useTransform(posY, (v) => r - v * zoom);
const innerTransform = useMotionTemplate`translate(${innerX}px, ${innerY}px) scale(${zoom})`;
// Specular arc drifts a few degrees off horizontal pointer velocity.
const vx = useVelocity(scx);
const arcDrift = useTransform(vx, (v) => clamp(v * 0.012, -16, 16));
const arcAngle = useTransform(arcDrift, (d) => -132 + d);
const emitActive = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternalActive(next);
onActiveChange?.(next);
},
[isControlled, onActiveChange]
);
// Measure the surface so the magnified copy can be sized 1:1 to it.
React.useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setSurface({ w: width, h: height });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// Keep the pinned lens parked on its coordinate as the surface resizes.
React.useEffect(() => {
if (!pinned || surface.w === 0) return;
cx.set(clamp(px, 0, 1) * surface.w);
cy.set(clamp(py, 0, 1) * surface.h);
}, [pinned, px, py, surface.w, surface.h, cx, cy]);
const setCenter = React.useCallback(
(nx: number, ny: number) => {
cx.set(clamp(nx, 0, surface.w));
cy.set(clamp(ny, 0, surface.h));
},
[cx, cy, surface.w, surface.h]
);
const handlePointerMove = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (pinned || event.pointerType === "touch") return;
const node = wrapperRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
cx.set(event.clientX - rect.left);
cy.set(event.clientY - rect.top);
},
[pinned, cx, cy]
);
const handlePointerEnter = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (pinned || event.pointerType === "touch") return;
const node = wrapperRef.current;
if (node) {
const rect = node.getBoundingClientRect();
cx.set(event.clientX - rect.left);
cy.set(event.clientY - rect.top);
}
emitActive(true);
},
[pinned, cx, cy, emitActive]
);
const handlePointerLeave = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (pinned || event.pointerType === "touch") return;
// Keep the lens up if focus is still driving it via keyboard.
if (wrapperRef.current?.contains(document.activeElement)) return;
emitActive(false);
},
[pinned, emitActive]
);
const handleFocus = React.useCallback(() => {
if (pinned) return;
// Focusing places the lens dead-center, ready to walk with the arrows.
setCenter(surface.w / 2, surface.h / 2);
emitActive(true);
}, [pinned, setCenter, surface.w, surface.h, emitActive]);
const handleBlur = React.useCallback(() => {
if (pinned) return;
emitActive(false);
}, [pinned, emitActive]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (pinned) return;
const step = keyboardStep;
let handled = true;
switch (event.key) {
case "ArrowLeft":
setCenter(cx.get() - step, cy.get());
break;
case "ArrowRight":
setCenter(cx.get() + step, cy.get());
break;
case "ArrowUp":
setCenter(cx.get(), cy.get() - step);
break;
case "ArrowDown":
setCenter(cx.get(), cy.get() + step);
break;
case "Home":
setCenter(0, 0);
break;
case "End":
setCenter(surface.w, surface.h);
break;
case "Escape":
(event.currentTarget as HTMLDivElement).blur();
break;
default:
handled = false;
}
if (handled) event.preventDefault();
},
[pinned, keyboardStep, setCenter, cx, cy, surface.w, surface.h]
);
const showLens = active && surface.w > 0;
return (
<div
ref={wrapperRef}
data-crucible="loupe"
tabIndex={0}
role="group"
aria-label="Magnifiable surface — focus and use arrow keys to inspect"
className={cn(
"relative isolate overflow-hidden rounded-xl outline-none",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
className
)}
onPointerMove={handlePointerMove}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
{...props}
>
{/* Base surface — blurs and dims a touch while the lens is up. */}
<div
className="h-full w-full"
style={{
filter: focusBlur && active ? `blur(${focusBlurStrength}px) brightness(0.82)` : "none",
transition: reducedMotion ? "none" : "filter 0.28s ease",
willChange: "filter",
}}
>
{children}
</div>
<AnimatePresence>
{showLens && (
<motion.div
key="lens"
aria-hidden
initial={
!animateEntrance || reducedMotion
? { opacity: 0, scale: 1 }
: { opacity: 0, scale: 0.92 }
}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: reducedMotion ? 1 : 0.98 }}
transition={{
opacity: { duration: 0.14, ease: "easeOut" },
scale: reducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 520, damping: 30, mass: 0.6 },
}}
className="pointer-events-none absolute overflow-hidden rounded-full"
style={{
left: lensLeft,
top: lensTop,
width: size,
height: size,
backgroundColor: "rgb(10 10 12)",
boxShadow:
"0 12px 34px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.06)",
}}
>
{/* Magnified copy, sized 1:1 to the surface then scaled. */}
<motion.div
className="absolute top-0 left-0"
style={{
width: surface.w,
height: surface.h,
transformOrigin: "0 0",
transform: innerTransform,
}}
>
{children}
</motion.div>
{/* Ground-glass edge falloff + a soft top-left sheen. */}
<div
className="pointer-events-none absolute inset-0 rounded-full"
style={{
background:
"radial-gradient(circle at 50% 50%, transparent 54%, rgba(0,0,0,0.28) 100%), radial-gradient(circle at 32% 28%, rgba(255,255,255,0.10), transparent 46%)",
}}
/>
{/* Specular arc riding the upper-left rim; drifts with velocity. */}
<motion.div
className="pointer-events-none absolute inset-0 rounded-full"
style={{
rotate: reducedMotion ? -132 : arcAngle,
background: `conic-gradient(from 0deg at 50% 50%, transparent 0deg, ${rimColor} 26deg, rgba(255,255,255,0.05) 70deg, transparent 120deg, transparent 360deg)`,
WebkitMask:
"radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2.5px))",
mask: "radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2.5px))",
opacity: 0.9,
}}
/>
{/* Hairline machined rim. */}
<div
className="pointer-events-none absolute inset-0 rounded-full"
style={{
boxShadow: `inset 0 0 0 1px ${rimColor}, inset 0 0 12px rgba(0,0,0,0.35)`,
}}
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/loupeManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| children | React.ReactNode | The surface to inspect — an image, an SVG, or any React subtree. | |
| zoom | number | 1.6 | Magnification inside the lens. |
| size | number | 176 | Lens diameter, in px. |
| pinned | boolean | false | Pin the lens at a fixed spot instead of tracking the pointer. |
| pinnedPosition | { x: number; y: number } | { x: 0.5, y: 0.5 } | Lens center as fractions of the surface (0–1) while pinned. |
| active | boolean | Controlled active state — when set, overrides internal hover/focus. | |
| onActiveChange | (active: boolean) => void | Fires whenever the lens opens or closes (pointer, focus, or keyboard). | |
| focusBlur | boolean | true | Softly blur + dim the surface outside the lens while it is active. |
| focusBlurStrength | number | 2 | Blur radius, in px, applied behind an active lens. |
| animateEntrance | boolean | true | Play the fast scale-settle entrance when the lens appears. |
| keyboardStep | number | 24 | Pixels the lens walks per arrow-key press while focused. |
| rimColor | string | "rgba(255,255,255,0.72)" | Rim + specular color of the machined bezel. |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"div">, "children"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.