【开发总结】实现一个“会吸附”的随动磁力光标


最近在优化自己的博客交互体验时,我尝试实现了一个比较有趣的小功能——Magnetic Cursor(磁力光标)

简单来说,就是当鼠标移动到按钮、链接等可交互元素附近时,光标会像“被吸引”一样自动靠近目标,并用一个动态取景框将元素包围起来。

第一次看到这个效果是参考了B站UP主 JIEJOE 的视频: 《“自瞄锁敌”?做一个带有磁力的网页光标》。当时第一反应是:这个效果很酷,但它真正有价值的地方并不是炫技,而是在细节上强化了用户和网页之间的互动反馈。

于是我决定把这个效果加入自己的博客。

同时参考了他的开源仓库:018-magnetic-pointer

不过原项目的技术栈和我的博客并不完全一致,所以我没有直接搬运代码,而是重新按照 React + Next.js 的架构进行了实现,主要借鉴其中的动画逻辑和交互思路。


最终效果

这个磁力光标并不是简单地替换浏览器默认鼠标。

我的实现方式是:

保留浏览器原本的交互逻辑,只增加一个独立的视觉层来模拟新的光标效果。

它包含几个状态:

  • 普通状态下,显示一个跟随鼠标移动的小型取景框;
  • 鼠标移动到按钮、链接等元素上时,取景框自动放大;
  • 放大后的边框会包围目标元素,并留出一定空间;
  • 整个过程不会改变元素尺寸,也不会影响原本的点击逻辑;
  • 用户可以在设置中心关闭该功能。

这种设计的好处是,视觉效果和页面逻辑完全分离。


实现思路

整个功能可以拆成几个部分:

  1. 监听鼠标移动;
  2. 判断当前鼠标下方是否存在可交互元素;
  3. 获取目标元素的位置和尺寸;
  4. 根据目标尺寸调整光标大小;
  5. 使用动画让状态平滑过渡。

整体流程大概如下:

鼠标移动
    ↓
判断是否命中交互元素
    ↓
普通状态:跟随鼠标的小光标
    ↓
交互状态:读取元素尺寸并吸附
    ↓
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 更新策略;
  • 无障碍支持;
  • 事件管理。

很多时候,一个看似简单的小动画,背后其实包含完整的工程思考。


总结

这次给博客加入磁力光标,最大的收获并不是完成了一个炫酷效果,而是在实践中重新理解了:

如何在不破坏原有结构的情况下,为网页增加更自然、更有反馈感的交互。

对于个人博客来说,这类细节并不会直接提升功能性,但它会影响用户浏览时的感受。


加载评论中…

发表评论