Skip to content

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
Full screen

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 and
3 * 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 the
11 * 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 <div
60 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 <span
83 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.

Card Stack, micro interactions preview: Card stack for React: photo cards you throw to the back with a drag or a flick, tap to pull one forward

Card Stack

Card stack for React: photo cards you throw to the back with a drag or a flick, tap to pull one forward. No dependencies.

Photo Sleeves, micro interactions preview: Photo album sleeves for React: pictures peek out, lift on hover and fan open on click

Photo Sleeves

Photo album sleeves for React: pictures peek out, lift on hover and fan open on click. Frosted pocket, zero dependencies.

Tilt Card, micro interactions preview: 3D tilt card for React: leans toward the pointer with a soft sheen and a drifting photo

Tilt Card

3D tilt card for React: leans toward the pointer with a soft sheen and a drifting photo. Lightweight, no animation library.

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.