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.
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
'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>
)
}
.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; }
}