Zoom Lens Magnifier on Product Image

A product layout with a picture on one side and the details on the other. A round glass sits on the picture and enlarges whatever is under it, so small print and fine detail can be read without opening a second image. The glass follows the mouse or a finger, moves with the arrow keys, and drifts slowly on its own when nobody is touching it. Three buttons change the magnification. On phones the picture sits above the text.

Code

// Adapted from Magic UI (https://magicui.design/docs/components/lens)
// Copyright (c) Magic UI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent, PointerEvent } from "react";
import { motion, useAnimationFrame, useMotionTemplate, useMotionValue, useReducedMotion, useSpring } from "framer-motion";

const product = {
  name: "Ridgeline No. 4",
  kind: "Topographic print, 50 x 70 cm",
  price: "$64",
  blurb: "Drawn from survey data and printed in three inks on 240 gsm cotton paper. Move the glass over the map to read the spot heights.",
  details: ["Signed and numbered, edition of 300", "Ships flat in 2 to 4 days", "Frame not included"],
};

const zoomLevels = [1.6, 2.2, 3];
/** Widest the glass gets, in pixels. It shrinks on narrow pictures. */
const LENS_MAX = 168;

const peaks = [
  { x: 250, y: 210, label: "Hollow Tarn", h: "1,412 m" },
  { x: 545, y: 330, label: "Larch Saddle", h: "1,688 m" },
  { x: 610, y: 150, label: "Greywether Top", h: "2,031 m" },
  { x: 170, y: 440, label: "Pike Howe", h: "1,174 m" },
];

/** The picture under the glass. Swap this for your own image or illustration. */
function MapArt() {
  return (
    <svg viewBox="0 0 800 600" className="block h-full w-full" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
      <rect width="800" height="600" fill="#f4ecdc" />
      {Array.from({ length: 17 }, (_, i) => (
        <line key={`v${i}`} x1={i * 50} y1="0" x2={i * 50} y2="600" stroke="#d9cdb4" strokeWidth="0.5" />
      ))}
      {Array.from({ length: 13 }, (_, i) => (
        <line key={`h${i}`} x1="0" y1={i * 50} x2="800" y2={i * 50} stroke="#d9cdb4" strokeWidth="0.5" />
      ))}
      <path d="M0 520 C120 470 180 560 300 520 S520 440 640 500 760 470 800 490 V600 H0Z" fill="#bcd3c4" />
      <path d="M0 548 C140 510 200 590 320 552 S540 480 660 536 770 508 800 524 V600 H0Z" fill="#a9c6b6" />
      {peaks.map((p, pi) =>
        Array.from({ length: 9 }, (_, i) => (
          <ellipse
            key={`${pi}-${i}`}
            cx={p.x + i * 3}
            cy={p.y + i * 2}
            rx={14 + i * (17 + pi * 2)}
            ry={9 + i * (12 + pi)}
            fill="none"
            stroke={i % 4 === 0 ? "#8a5a34" : "#b98b5e"}
            strokeWidth={i % 4 === 0 ? 1.1 : 0.6}
            transform={`rotate(${-18 + pi * 23} ${p.x} ${p.y})`}
          />
        )),
      )}
      <path d="M40 560 C150 470 190 430 250 400 S380 380 430 330 520 250 610 150" fill="none" stroke="#c2410c" strokeWidth="2" strokeDasharray="7 5" strokeLinecap="round" />
      <path d="M0 300 C90 330 150 290 230 320 S360 380 470 350 640 420 800 380" fill="none" stroke="#4a86a8" strokeWidth="2.4" strokeLinecap="round" />
      {peaks.map((p) => (
        <g key={p.label}>
          <path d={`M${p.x} ${p.y - 6} l5 9 h-10z`} fill="#3b2a1a" />
          <text x={p.x + 10} y={p.y - 2} fontSize="10" fontWeight="700" fill="#3b2a1a" fontFamily="ui-sans-serif, system-ui">{p.label}</text>
          <text x={p.x + 10} y={p.y + 9} fontSize="7.5" fill="#6b5137" fontFamily="ui-monospace, monospace">{p.h}</text>
        </g>
      ))}
      <text x="318" y="372" fontSize="7" fill="#c2410c" fontFamily="ui-monospace, monospace" transform="rotate(-24 318 372)">Shepherd's Way, 11.4 km</text>
      <text x="96" y="306" fontSize="7" fill="#2f6887" fontStyle="italic" fontFamily="ui-serif, Georgia" transform="rotate(9 96 306)">Wren Beck</text>
      <text x="700" y="560" fontSize="7" fill="#3f6b57" fontFamily="ui-monospace, monospace">Aldermere Forest</text>
      <g transform="translate(716 76)">
        <circle r="26" fill="#f4ecdc" stroke="#3b2a1a" strokeWidth="1" />
        <path d="M0 -20 L5 0 L0 20 L-5 0Z" fill="#3b2a1a" />
        <path d="M0 -20 L5 0 L-5 0Z" fill="#c2410c" />
        <text y="-30" fontSize="9" textAnchor="middle" fontWeight="700" fill="#3b2a1a" fontFamily="ui-sans-serif, system-ui">N</text>
      </g>
      <g transform="translate(40 40)" fontFamily="ui-monospace, monospace" fill="#3b2a1a">
        <text fontSize="13" fontWeight="700" letterSpacing="3">RIDGELINE NO. 4</text>
        <text y="14" fontSize="7" letterSpacing="1.5">SCALE 1:25 000 ยท CONTOURS EVERY 20 M</text>
      </g>
    </svg>
  );
}

export default function ZoomLensMagnifierOnProductImage() {
  const reduce = useReducedMotion();
  const frame = useRef<HTMLDivElement>(null);
  const size = useRef({ w: 640, h: 480 });
  const manualUntil = useRef(0);
  const [zoom, setZoom] = useState(zoomLevels[1]);
  const [LENS, setLens] = useState(LENS_MAX);

  const tx = useMotionValue(380);
  const ty = useMotionValue(210);
  const x = useSpring(tx, { stiffness: 260, damping: 30, mass: 0.6 });
  const y = useSpring(ty, { stiffness: 260, damping: 30, mass: 0.6 });
  const mask = useMotionTemplate`radial-gradient(circle ${LENS / 2}px at ${x}px ${y}px, #000 99%, transparent 100%)`;
  const origin = useMotionTemplate`${x}px ${y}px`;

  useEffect(() => {
    const el = frame.current;
    if (!el) return;
    const measure = () => {
      size.current = { w: el.clientWidth, h: el.clientHeight };
      setLens(Math.min(LENS_MAX, Math.round(el.clientWidth * 0.34)));
      tx.set(el.clientWidth * 0.68);
      ty.set(el.clientHeight * 0.3);
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, [tx, ty]);

  // Idle tour: the glass drifts over the picture until someone takes over.
  useAnimationFrame((t) => {
    if (reduce || t < manualUntil.current) return;
    const { w, h } = size.current;
    tx.set(w * (0.5 + 0.34 * Math.cos(t / 2600)));
    ty.set(h * (0.48 + 0.3 * Math.sin(t / 1900)));
  });

  const hold = (ms: number) => { manualUntil.current = performance.now() + ms; };

  const onPointer = (e: PointerEvent<HTMLDivElement>) => {
    const r = e.currentTarget.getBoundingClientRect();
    tx.set(e.clientX - r.left);
    ty.set(e.clientY - r.top);
    hold(60000);
  };

  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
    const step = e.shiftKey ? 48 : 20;
    const move: Record<string, [number, number]> = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
    const d = move[e.key];
    if (!d) return;
    e.preventDefault();
    const { w, h } = size.current;
    tx.set(Math.min(w, Math.max(0, tx.get() + d[0])));
    ty.set(Math.min(h, Math.max(0, ty.get() + d[1])));
    hold(8000);
  };

  return (
    <section className="bg-stone-100 px-4 py-14 sm:px-6 lg:py-20">
      <div className="mx-auto grid max-w-6xl items-center gap-10 lg:grid-cols-[1.25fr_1fr] lg:gap-14">
        <div
          ref={frame}
          role="region"
          aria-label="Map preview with magnifying glass. Use the arrow keys to move the glass."
          tabIndex={0}
          onPointerMove={onPointer}
          onPointerDown={onPointer}
          onPointerLeave={() => hold(900)}
          onKeyDown={onKey}
          className="relative aspect-[4/3] w-full cursor-none touch-pan-y select-none overflow-hidden rounded-2xl bg-[#f4ecdc] shadow-xl shadow-stone-900/10 ring-1 ring-stone-900/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-600 focus-visible:ring-offset-4 focus-visible:ring-offset-stone-100"
        >
          <MapArt />
          {/* The same picture again, scaled up and cut to a circle. */}
          <motion.div className="pointer-events-none absolute inset-0 overflow-hidden" style={{ maskImage: mask, WebkitMaskImage: mask }}>
            <motion.div className="absolute inset-0" animate={{ scale: zoom }} transition={{ type: "spring", stiffness: 220, damping: 24 }} style={{ transformOrigin: origin }}>
              <MapArt />
            </motion.div>
          </motion.div>
          {/* The rim of the glass. */}
          <motion.div
            aria-hidden="true"
            className="pointer-events-none absolute left-0 top-0 rounded-full border-[3px] border-white shadow-[0_18px_40px_-8px_rgba(41,29,15,0.55),inset_0_0_0_1px_rgba(41,29,15,0.25),inset_0_10px_24px_rgba(255,255,255,0.35)]"
            style={{ x, y, width: LENS, height: LENS, marginLeft: -LENS / 2, marginTop: -LENS / 2 }}
          />
          <span className="pointer-events-none absolute bottom-3 left-3 rounded-full bg-stone-900/80 px-3 py-1 font-mono text-[11px] font-medium text-stone-50">{zoom.toFixed(1)}x</span>
        </div>

        <div>
          <p className="text-sm font-semibold uppercase tracking-widest text-orange-700">Fellside Press</p>
          <h2 className="mt-3 text-4xl font-semibold tracking-tight text-stone-900 sm:text-5xl">{product.name}</h2>
          <p className="mt-2 text-stone-600">{product.kind}</p>
          <p className="mt-6 max-w-md text-lg leading-relaxed text-stone-700">{product.blurb}</p>

          <div className="mt-7">
            <p id="zoom-lens-magnifier-label" className="text-sm font-medium text-stone-700">Magnification</p>
            <div role="group" aria-labelledby="zoom-lens-magnifier-label" className="mt-2 inline-flex rounded-full bg-stone-200 p-1">
              {zoomLevels.map((z) => (
                <button
                  key={z}
                  type="button"
                  aria-pressed={zoom === z}
                  onClick={() => setZoom(z)}
                  className={`h-10 min-w-[64px] rounded-full px-4 font-mono text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-600 ${zoom === z ? "bg-white text-stone-900 shadow" : "text-stone-600 hover:text-stone-900"}`}
                >
                  {z.toFixed(1)}x
                </button>
              ))}
            </div>
          </div>

          <ul className="mt-7 space-y-2 text-sm text-stone-600">
            {product.details.map((d) => (
              <li key={d} className="flex items-center gap-2.5">
                <span className="h-1.5 w-1.5 rounded-full bg-orange-600" aria-hidden="true" />
                {d}
              </li>
            ))}
          </ul>

          <div className="mt-8 flex flex-wrap items-center gap-5">
            <button type="button" className="h-12 rounded-full bg-stone-900 px-7 text-sm font-semibold text-white transition-colors hover:bg-stone-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-600 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-100">
              Add to basket
            </button>
            <span className="text-2xl font-semibold tracking-tight text-stone-900">{product.price}</span>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/zoom-lens-magnifier-on-product-image.json

When to use it

How to customize it

Accessibility

Questions

How does the magnifying glass work?

The picture is drawn twice in the same place. The second copy is scaled up with its transform origin set to the pointer position, then cut down to a circle with a radial gradient used as a CSS mask. Because the origin and the mask centre are the same point, the enlarged part lines up with what is underneath.

Do I need a larger version of the image?

Not to make it work. The enlarged copy is the same element scaled with CSS. For photographs, use a source file about three times the displayed size so the zoomed view stays sharp. Vector art stays sharp at any level.

Does it work on touch screens?

Yes. It uses pointer events, so a tap or a drag moves the glass. Vertical swipes still scroll the page because the frame uses `touch-pan-y`.

Why does the glass move without re-rendering the picture?

The position is held in motion values rather than React state. The mask and transform origin are written straight to the element style on each frame, so React does not render the picture again while the pointer moves.

Can I show the glass only on hover?

Yes. Wrap the two glass layers in a condition driven by a hover state set from `onPointerEnter` and `onPointerLeave`, and remove the idle drift block.

Adapted from Lens in Magic UI, used under its open-source licence. Licences and credits

Related: Product Card with Price, Rating, Colour Swatches and Add to Cart, Coverflow Gallery with 3D Perspective and Autoplay, 3D Tilt Card with Parallax Layers and Glare.

More galleries and carousels or all free UI components. Start for free to have a full site built around it.

More in Galleries

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap