十字准星光标
两条细线横跨整个区域并在指针处交汇,同时实时显示坐标,像设计工具里的标尺。
移入区域
用 React 在限定区域内制作十字准星光标,不使用第三方库。区域内用 cursor: none 隐藏原生光标。渲染一条满宽的水平细线和一条满高的垂直细线(1px,60% 不透明度的主色),以及一个小的等宽字体坐标标签。pointermove 时读取指针相对区域的位置,在 requestAnimationFrame 中通过 ref 直接设置细线和标签的 translate,并把整数像素的“x, y”写入标签。pointerenter 时显示,pointerleave 时以 150ms 的 opacity 过渡淡出。标签靠近右侧和底部边缘时翻转偏移方向,避免溢出。细线设为 aria-hidden。指针在区域外时显示居中的提示文字。提示文字为“移入区域”。
源代码
'use client'
import { useRef } from 'react'
import s from './demo.module.css'
export default function CrosshairCursor({ labels }: { labels: Record<string, string> }) {
const area = useRef<HTMLDivElement>(null)
const h = useRef<HTMLSpanElement>(null)
const v = useRef<HTMLSpanElement>(null)
const tag = useRef<HTMLSpanElement>(null)
const raf = useRef(0)
const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect()
const x = Math.round(e.clientX - r.left)
const y = Math.round(e.clientY - r.top)
cancelAnimationFrame(raf.current)
raf.current = requestAnimationFrame(() => {
h.current!.style.translate = `0 ${y}px`
v.current!.style.translate = `${x}px 0`
const t = tag.current!
const dx = x > r.width - 80 ? -t.offsetWidth - 8 : 8
const dy = y > r.height - 30 ? -t.offsetHeight - 8 : 8
t.style.translate = `${x + dx}px ${y + dy}px`
t.textContent = `${x}, ${y}`
})
}
return (
<div
ref={area}
className={s.area}
onPointerMove={onMove}
onPointerEnter={() => area.current?.setAttribute('data-on', '')}
onPointerLeave={() => area.current?.removeAttribute('data-on')}
>
<span className={s.hint}>{labels.hint}</span>
<span ref={h} className={s.h} aria-hidden="true" />
<span ref={v} className={s.v} aria-hidden="true" />
<span ref={tag} className={s.tag} aria-hidden="true" />
</div>
)
}
.area {
position: absolute;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
cursor: none;
background:
repeating-linear-gradient(90deg, var(--stage-surface) 0 1px, transparent 1px 24px),
repeating-linear-gradient(0deg, var(--stage-surface) 0 1px, transparent 1px 24px),
var(--stage-bg);
}
.hint {
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--stage-muted);
transition: opacity 150ms;
}
.h, .v, .tag {
position: absolute;
left: 0;
top: 0;
opacity: 0;
pointer-events: none;
transition: opacity 150ms;
}
.h { width: 100%; height: 1px; background: color-mix(in oklch, var(--stage-accent) 60%, transparent); }
.v { width: 1px; height: 100%; background: color-mix(in oklch, var(--stage-accent) 60%, transparent); }
.tag {
padding: 3px 6px;
border-radius: 4px;
background: var(--stage-accent);
color: var(--stage-accent-ink);
font: 600 0.6875rem / 1 var(--font-mono);
white-space: nowrap;
}
.area[data-on] .h,
.area[data-on] .v,
.area[data-on] .tag { opacity: 1; }
.area[data-on] .hint { opacity: 0; }