Photo Lens
Image magnifier for React: a round lens follows the pointer or your finger and shows the photo enlarged. No canvas, no dependency.
- Micro-interactions
- E-commerce
- React only · 0 deps
- 1 file
Browser support · All evergreen browsers. Pass the picture's real height/width as ratio. Photos are plain URLs you pass in; if one fails to load, a soft gradient stays in its place. The demo uses /demo/*.webp, swap in your own files.
Install. One command, or copy the file.
$
npx shadcn@latest add https://freedesignmd.com/r/photo-lens.json- Byte-identical to the preview
- No Tailwind or config needed
- SSR-safe · keyboard accessible
- Respects reduced motion & transparency
components/micro-interactions/photo-lens.tsx
1/**2 * PhotoLens, a round magnifier that follows the pointer over a photo and3 * shows it enlarged. On touch it follows your finger, floating above it.4 *5 * Props: src, width (px, default 280), ratio (height / width of the photo,6 * default 1.333; set it to the picture's real ratio), zoom (default 2.4),7 * lens (diameter in px, default 132), aria-label.8 * CSS vars: --fd-pl-radius.9 *10 * Browser support: all evergreen browsers. The lens is a second layer of the11 * same picture, no canvas, no network request.12 * Accessibility: decorative magnification; the photo keeps its aria-label.13 * Reduced motion: the lens snaps without easing.14 */15import { useRef, useState } from "react";16import type { PointerEvent } from "react";17 18export interface PhotoLensProps {19 src: string;20 width?: number;21 ratio?: number;22 zoom?: number;23 lens?: number;24 className?: string;25 "aria-label"?: string;26}27 28export function PhotoLens({29 src,30 width = 280,31 ratio = 4 / 3,32 zoom = 2.4,33 lens = 132,34 className,35 "aria-label": label = "Photo",36}: PhotoLensProps) {37 const height = Math.round(width * ratio);38 const [pos, setPos] = useState({ x: width * 0.7, y: height * 0.58 });39 const [live, setLive] = useState(false);40 const [touch, setTouch] = useState(false);41 const box = useRef<HTMLDivElement>(null);42 const [ok, setOk] = useState(true);43 44 const place = (e: PointerEvent<HTMLDivElement>) => {45 const r = box.current?.getBoundingClientRect();46 if (!r) return;47 setPos({48 x: Math.min(Math.max(e.clientX - r.left, 0), width),49 y: Math.min(Math.max(e.clientY - r.top, 0), height),50 });51 };52 53 const r = lens / 2;54 const lift = touch ? r + 28 : 0;55 const lx = pos.x;56 const ly = pos.y - lift;57 58 return (59 <div60 ref={box}61 className={`fd-pl${className ? ` ${className}` : ""}`}62 role="img"63 aria-label={label}64 data-live={live || undefined}65 style={{ width, height }}66 onPointerEnter={(e) => {67 setTouch(e.pointerType === "touch");68 setLive(true);69 place(e);70 }}71 onPointerDown={(e) => {72 setTouch(e.pointerType === "touch");73 setLive(true);74 place(e);75 }}76 onPointerMove={place}77 onPointerLeave={() => setLive(false)}78 onPointerUp={(e) => e.pointerType === "touch" && setLive(false)}79 >80 <style>{css}</style>81 {ok ? <img className="fd-pl-img" src={src} alt="" draggable={false} decoding="async" onError={() => setOk(false)} /> : null}82 <span83 className="fd-pl-lens"84 aria-hidden="true"85 style={{86 width: lens,87 height: lens,88 transform: `translate3d(${lx - r}px, ${ly - r}px, 0)`,89 backgroundImage: ok ? `url(${src})` : undefined,90 backgroundSize: `${width * zoom}px ${height * zoom}px`,91 backgroundPosition: `${r - pos.x * zoom}px ${r - pos.y * zoom}px`,92 }}93 />94 </div>95 );96}97 98const FONT = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif`;99const css = `100.fd-pl,.fd-pl *,.fd-pl *::before,.fd-pl *::after{box-sizing:border-box}101.fd-pl{--fd-pl-radius:22px;--fd-ease-out:cubic-bezier(0.22,1,0.36,1);position:relative;display:block;margin:0;padding:0;border-radius:var(--fd-pl-radius);background:linear-gradient(160deg,#dcd0c8,#9aa3b8);box-shadow:0 1px 2px rgba(0,0,0,.1),0 20px 38px -16px rgba(0,0,0,.4);font:400 14px/1.4 ${FONT};cursor:none;touch-action:none;-webkit-user-select:none;user-select:none;overflow:hidden}102.fd-pl-img{position:absolute;inset:0;display:block;width:100%;height:100%;margin:0;padding:0;border:0;object-fit:cover;pointer-events:none}103.fd-pl-lens{position:absolute;left:0;top:0;display:block;margin:0;padding:0;border-radius:50%;background-color:#d8d2cc;background-repeat:no-repeat;border:2px solid rgba(255,255,255,.9);box-shadow:0 0 0 1px rgba(0,0,0,.12),0 10px 24px -6px rgba(0,0,0,.45),inset 0 0 18px rgba(0,0,0,.12);pointer-events:none;will-change:transform;scale:1;transition:scale 360ms var(--fd-ease-out)}104.fd-pl-lens::after{content:"";position:absolute;inset:0;border-radius:50%;background:radial-gradient(60% 40% at 32% 22%,rgba(255,255,255,.35),transparent 70%)}105.fd-pl:not([data-live]) .fd-pl-lens{scale:.9}106@media (hover:none){.fd-pl{cursor:default}}107@media (prefers-reduced-motion:reduce){.fd-pl-lens{transition:none}}108`;109 110export function Demo() {111 return (112 <div className="fd-pld">113 <style>{demoCss}</style>114 <PhotoLens src="/demo/butterfly-p.webp" ratio={4 / 3} aria-label="Butterfly" />115 </div>116 );117}118 119const demoCss = `120.fd-pld,.fd-pld *,.fd-pld *::before,.fd-pld *::after{box-sizing:border-box}121.fd-pld{display:flex;align-items:center;justify-content:center;width:100%;min-height:100vh;margin:0;padding:48px 20px;background:#ececec;font:400 14px/1.4 ${FONT};letter-spacing:normal;overflow:hidden}122`;More Micro-interactions. Same file contract.
Questions about Photo Lens
Run npx shadcn@latest add https://freedesignmd.com/r/photo-lens.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.
Image magnifier for React: a round lens follows the pointer or your finger and shows the photo enlarged. No canvas, no dependency. It is a free, MIT-licensed React micro-interactions component from the freedesignmd library.
Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.
All evergreen browsers. Pass the picture's real height/width as ratio. Photos are plain URLs you pass in; if one fails to load, a soft gradient stays in its place. The demo uses /demo/*.webp, swap in your own files.
Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.


