【开发总结】实现一个“会吸附”的随动磁力光标
最近在优化自己的博客交互体验时,我尝试实现了一个比较有趣的小功能——Magnetic Cursor(磁力光标)。
简单来说,就是当鼠标移动到按钮、链接等可交互元素附近时,光标会像“被吸引”一样自动靠近目标,并用一个动态取景框将元素包围起来。
第一次看到这个效果是参考了B站UP主 JIEJOE 的视频: 《“自瞄锁敌”?做一个带有磁力的网页光标》。当时第一反应是:这个效果很酷,但它真正有价值的地方并不是炫技,而是在细节上强化了用户和网页之间的互动反馈。
于是我决定把这个效果加入自己的博客。
同时参考了他的开源仓库:018-magnetic-pointer
不过原项目的技术栈和我的博客并不完全一致,所以我没有直接搬运代码,而是重新按照 React + Next.js 的架构进行了实现,主要借鉴其中的动画逻辑和交互思路。
最终效果
这个磁力光标并不是简单地替换浏览器默认鼠标。
我的实现方式是:
保留浏览器原本的交互逻辑,只增加一个独立的视觉层来模拟新的光标效果。
它包含几个状态:
- 普通状态下,显示一个跟随鼠标移动的小型取景框;
- 鼠标移动到按钮、链接等元素上时,取景框自动放大;
- 放大后的边框会包围目标元素,并留出一定空间;
- 整个过程不会改变元素尺寸,也不会影响原本的点击逻辑;
- 用户可以在设置中心关闭该功能。
这种设计的好处是,视觉效果和页面逻辑完全分离。
实现思路
整个功能可以拆成几个部分:
- 监听鼠标移动;
- 判断当前鼠标下方是否存在可交互元素;
- 获取目标元素的位置和尺寸;
- 根据目标尺寸调整光标大小;
- 使用动画让状态平滑过渡。
整体流程大概如下:
鼠标移动
↓
判断是否命中交互元素
↓
普通状态:跟随鼠标的小光标
↓
交互状态:读取元素尺寸并吸附
↓
requestAnimationFrame 更新视觉层
为什么没有直接使用 React State?
这是实现过程中比较重要的一点。
鼠标移动事件的触发频率非常高,如果每一次 pointermove 都调用 React 的 setState:
setCursorPosition({
x,
y
})
那么 React 会不断触发组件更新。
对于这种高频动画场景,这并不是一个好的选择。
所以我的处理方式是:
- React 只负责组件生命周期和功能开关;
- 光标的位置、尺寸、目标元素等临时状态保存在普通变量中;
- 直接通过 DOM 更新视觉层。
简单来说:
React 管“什么时候存在”,DOM 管“每一帧怎么动”。
如何判断当前元素是否需要吸附?
这里使用了事件委托。
并不是给页面上的每个按钮、链接分别绑定事件,而是监听全局 pointermove:
window.addEventListener(
"pointermove",
handlePointerMove
)
然后通过:
event.target.closest()
向上寻找最近的可交互元素。
例如:
const CLICKABLE_SELECTOR = [
"a[href]",
"button:not(:disabled)",
"input:not(:disabled)",
"select:not(:disabled)",
"textarea:not(:disabled)",
"[role='button']",
"[data-magnetic-target]"
].join(",");
这样做有几个优势:
- 动态生成的文章卡片也能自动支持;
- 不需要给每个组件额外添加逻辑;
- 后续扩展新的交互元素更方便。
如果某些区域不希望触发磁力效果,也可以通过:
<div data-magnetic-ignore>
...
</div>
进行排除。
实现过程中最容易踩坑的地方:坐标系统
磁力光标最麻烦的部分其实不是动画,而是坐标计算。
getBoundingClientRect() 返回的是相对于浏览器视口的坐标:
const rect = element.getBoundingClientRect();
包括:
rect.left
rect.top
rect.width
rect.height
我的光标层使用的是:
position: fixed;
所以它和 getBoundingClientRect() 使用的是同一个坐标体系。
这里不需要额外加:
window.scrollX
window.scrollY
否则滚动之后反而会产生偏移。
为什么使用 requestAnimationFrame?
另一个性能优化点是动画更新。
鼠标事件可能在一帧内触发多次,如果每次事件都立即修改样式,会造成重复计算。
所以我让鼠标事件只负责记录最新状态:
pointermove
↓
requestAnimationFrame
↓
统一更新 DOM
这样浏览器可以按照自己的刷新节奏执行动画。
同时,我也监听了:
- scroll
- resize
因为即使鼠标不移动,目标元素的位置也可能发生变化。
四角取景框的实现
视觉效果上,我没有使用完整 border,而是拆成四个角:
<div className="magnetic-cursor">
<span />
<span />
<span />
<span />
</div>
这样可以实现类似扫描框的感觉。
每个角只绘制两条边:
.magnetic-cursor__corner {
border: 0 solid var(--primary);
}
相比完整矩形边框:
- 更像游戏中的目标锁定效果;
- 放大缩小时视觉变化更加明显;
- 小尺寸状态下不会显得笨重。
这里还有一个细节:
角线长度不能固定太大,否则默认的小光标会直接变成完整方框。
所以限制为:
width: min(13px, 32%);
height: min(13px, 32%);
让它随着容器尺寸变化。
系统鼠标隐藏
为了让自定义光标效果完整显示,我在支持精细指针的设备上隐藏了系统鼠标:
body.magnetic-cursor-active *,
body.magnetic-cursor-active {
cursor: none !important;
}
不过这个功能不能无条件开启。
需要考虑:
- 移动设备;
- 粗指针设备;
- 用户减少动画偏好;
- 组件卸载。
另外,光标视觉层必须设置:
pointer-events: none;
否则它会挡住下面真正的按钮,导致点击失效。
加入设置中心
我不希望这个效果成为强制功能。
因此增加了一个开关:
设置中心
└── 交互体验
└── 磁力光标
状态保存在 Zustand store 中,并同步到 localStorage。
这样用户关闭后,下次访问仍然保持自己的选择。
这次实现后的几个收获
这个小功能看起来只是一个视觉效果,但实际过程中涉及了不少前端细节:
1. 动画不一定需要 React State
React 非常适合管理状态,但并不是所有状态都应该进入 React。
对于高频动画:
- 鼠标位置;
- 滚动位置;
- 拖拽坐标;
直接操作 DOM 往往更加合适。
2. 交互体验需要考虑“退出机制”
一个好的视觉效果,不应该强迫所有用户接受。
提供关闭入口,同时尊重:
- 用户设备能力;
- 动画偏好;
- 可访问性需求;
这些同样属于体验设计的一部分。
3. 越小的效果,越容易暴露细节问题
磁力光标只有几十行核心逻辑,但里面涉及:
- 坐标体系;
- 性能优化;
- DOM 更新策略;
- 无障碍支持;
- 事件管理。
很多时候,一个看似简单的小动画,背后其实包含完整的工程思考。
总结
这次给博客加入磁力光标,最大的收获并不是完成了一个炫酷效果,而是在实践中重新理解了:
如何在不破坏原有结构的情况下,为网页增加更自然、更有反馈感的交互。
对于个人博客来说,这类细节并不会直接提升功能性,但它会影响用户浏览时的感受。