Magnifier Lens

A round lens follows the pointer and shows the detail underneath at twice the size, for reading fine print or inspecting a map.

  • JS
  • Cursor
  • Pointer
  • Added
Elm StHarbour RdMill LnPark AveQuayOld Bridge
Hover to magnify

AI prompt

Create a magnifier lens that follows the pointer over some content, in React. Render the content once normally and once more inside a circular lens element (overflow hidden, 120px, a light ring and shadow); mark the copy aria-hidden. Size the copy exactly like the original and give it transform-origin 0 0. On pointermove, with the pointer at (x, y) relative to the content, place the lens centred on the pointer with translate(x − r, y − r), and transform the copy inside it with translate(r − x × 2, r − y × 2) scale(2), so the point under the cursor appears magnified at the lens centre. Show the lens on pointerenter and hide it on pointerleave with a 150ms scale and opacity transition. Hide the native cursor over the content. Update positions in a requestAnimationFrame. The content is a small street map with labels: Elm St, Harbour Rd, Mill Ln, Park Ave, Quay, and Old Bridge.

Source

Demo.tsx
'use client'

import { useRef } from 'react'
import s from './demo.module.css'

const R = 60
const ZOOM = 2
const SPOTS = [
  [14, 22], [52, 14], [70, 46], [24, 64], [80, 80], [44, 84],
]

function MapArt({ streets }: { streets: string[] }) {
  return (
    <div className={s.map}>
      <span className={s.river} />
      {streets.map((t, i) => (
        <span key={t} className={s.street} style={{ left: `${SPOTS[i][0]}%`, top: `${SPOTS[i][1]}%` }}>
          {t}
        </span>
      ))}
      <span className={s.pin} />
    </div>
  )
}

export default function MagnifierLens({ labels }: { labels: Record<string, string> }) {
  const area = useRef<HTMLDivElement>(null)
  const lens = useRef<HTMLDivElement>(null)
  const copy = useRef<HTMLDivElement>(null)
  const raf = useRef(0)
  const streets = labels.streets.split('|')

  const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
    const r = e.currentTarget.getBoundingClientRect()
    const x = e.clientX - r.left
    const y = e.clientY - r.top
    cancelAnimationFrame(raf.current)
    raf.current = requestAnimationFrame(() => {
      // the copy must match the area, not the lens, so the zoomed point lines up
      copy.current!.style.width = `${r.width}px`
      copy.current!.style.height = `${r.height}px`
      lens.current!.style.translate = `${x - R}px ${y - R}px`
      copy.current!.style.transform = `translate(${R - x * ZOOM}px, ${R - y * ZOOM}px) scale(${ZOOM})`
    })
  }

  return (
    <div
      ref={area}
      className={s.area}
      onPointerMove={onMove}
      onPointerEnter={() => area.current?.setAttribute('data-on', '')}
      onPointerLeave={() => area.current?.removeAttribute('data-on')}
    >
      <MapArt streets={streets} />
      <span className={s.hint}>{labels.hint}</span>
      <div ref={lens} className={s.lens} aria-hidden="true">
        <div ref={copy} className={s.copy}>
          <MapArt streets={streets} />
        </div>
      </div>
    </div>
  )
}
demo.module.css
.area {
  position: absolute;
  inset: 0;
  overflow: hidden;
  cursor: none;
}

.map,
.copy {
  position: absolute;
  inset: 0;
}

.copy {
  width: 100%;
  height: 100%;
  transform-origin: 0 0;
}

.map {
  background:
    repeating-linear-gradient(90deg, transparent 0 46px, var(--stage-rule) 46px 48px),
    repeating-linear-gradient(0deg, transparent 0 38px, var(--stage-rule) 38px 40px),
    var(--stage-surface);
}

.river {
  position: absolute;
  inset: 40% -10% auto;
  height: 22px;
  background: oklch(48% 0.07 230);
  transform: rotate(-12deg);
}

.street {
  position: absolute;
  padding: 1px 3px;
  border-radius: 2px;
  background: var(--stage-bg);
  color: var(--stage-muted);
  font: 500 7px / 1 var(--font-mono);
  white-space: nowrap;
}

.pin {
  position: absolute;
  left: 61%;
  top: 34%;
  width: 8px;
  height: 8px;
  border: 2px solid var(--stage-ink);
  border-radius: 50%;
  background: var(--stage-accent);
}

.hint {
  position: absolute;
  left: 50%;
  bottom: 10px;
  translate: -50% 0;
  padding: 4px 8px;
  border-radius: 6px;
  background: var(--stage-bg);
  font: 500 var(--text-xs) / 1 var(--font-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--stage-muted);
  transition: opacity 150ms;
}

.lens {
  position: absolute;
  left: 0;
  top: 0;
  width: 120px;
  height: 120px;
  overflow: hidden;
  border-radius: 50%;
  box-shadow: 0 0 0 2px var(--stage-ink), 0 10px 30px oklch(0% 0 0 / 0.5);
  opacity: 0;
  scale: 0.8;
  pointer-events: none;
  transition: opacity 150ms, scale 150ms var(--ease-out);
}

.area[data-on] .lens { opacity: 1; scale: 1; }
.area[data-on] .hint { opacity: 0; }

@media (prefers-reduced-motion: reduce) {
  .lens { transition: none; }
}

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.