放大镜
一个圆形镜片跟随指针,把下方的细节放大两倍,方便阅读小字或查看地图。
榆树街港湾路磨坊巷公园大道码头旧桥
悬停放大用 React 制作跟随指针的放大镜。内容正常渲染一次,再在一个圆形镜片元素中渲染一份副本(overflow hidden,120px,带浅色描边和阴影),副本设为 aria-hidden。副本尺寸与原内容完全一致,transform-origin 为 0 0。pointermove 时,设指针相对内容的位置为 (x, y),用 translate(x − r, y − r) 让镜片以指针为中心,并把镜片里的副本设为 translate(r − x × 2, r − y × 2) scale(2),使光标下方的那一点被放大显示在镜片中心。pointerenter 时显示镜片,pointerleave 时隐藏,配合 150ms 的缩放和透明度过渡。内容上方隐藏原生光标。在 requestAnimationFrame 中更新位置。内容是一张带标注的小型街道地图:榆树街、港湾路、磨坊巷、公园大道、码头、旧桥。
源代码
'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; }
}