放大镜

一个圆形镜片跟随指针,把下方的细节放大两倍,方便阅读小字或查看地图。

  • JS
  • 光标
  • 跟随指针
  • 收录于
榆树街港湾路磨坊巷公园大道码头旧桥
悬停放大

AI 提示词

用 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 中更新位置。内容是一张带标注的小型街道地图:榆树街、港湾路、磨坊巷、公园大道、码头、旧桥。

源代码

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; }
}

投稿创意

看到想收录的动效或主题?简单描述一下,会打开一个预先填好的 GitHub Issue,确认后即可提交。

你想投稿的是?

需要 GitHub 账号,投稿内容会公开。