使用 Canvas 和 mix-blend-mode 实现跟随鼠标的反色圆圈

使用 Canvas 和 mix-blend-mode 实现跟随鼠标的反色圆圈

在一些强调视觉表现的官网中,我们经常能看到这样的交互:鼠标移动到标题上时,会出现一个带有惯性的深色圆圈;圆圈经过文字时,文字也会自动反色。

这个效果看起来像是给文字增加了遮罩,实际上可以用一个全屏 Canvas 配合 CSS 的 mix-blend-mode: difference 很轻量地实现。

本文将实现下面几个效果:

  • 圆圈只在鼠标进入指定文字时出现;
  • 圆圈带有平滑的惯性跟随效果;
  • 圆圈经过背景和文字时自动反色;
  • 离开文字后缩小并淡出;
  • 支持高分屏,并在触屏设备上自动关闭。

一、实现思路

页面主要由三部分组成:

  1. 一个用于展示标题的区域;
  2. 一个覆盖整个浏览器窗口的透明 Canvas;
  3. 一段监听鼠标位置并绘制圆形的 JavaScript。

HTML 结构很简单:

html 复制代码
<div class="cursor-region">
  <h1>
    <span data-cursor="blend">探索未至之境</span>
  </h1>
</div>

<canvas class="cursor-canvas" aria-hidden="true"></canvas>

data-cursor="blend" 用来标记需要触发圆圈效果的元素。这样以后即使页面上有多个标题,也可以复用同一套判断逻辑。

二、为什么画白色圆圈,却显示成深色

这个效果的关键 CSS 是:

css 复制代码
.cursor-canvas {
  position: fixed;
  inset: 0;
  z-index: 9999;
  width: 100vw;
  height: 100vh;
  pointer-events: none;
  mix-blend-mode: difference;
}

其中,mix-blend-mode: difference 表示差值混合。可以把它粗略理解为:

text 复制代码
最终颜色 = |背景颜色 - 当前图层颜色|

JavaScript 在 Canvas 中画的其实是白色圆形:

js 复制代码
context.fillStyle = "#fff";

白色与页面背景进行差值混合后,背景颜色会被反相。例如浅蓝色会变成偏深的褐色,深蓝色文字则会变成浅黄色。因此,不需要复制标题,也不需要为文字单独制作遮罩,就能让圆圈和文字同时产生反色效果。

另外,pointer-events: none 非常重要。它可以防止全屏 Canvas 拦截鼠标事件,否则标题将无法正常收到 pointermove 等事件。

三、实现惯性跟随

如果每次鼠标移动都直接把圆心设置为鼠标坐标,圆圈会紧贴鼠标,视觉上比较生硬。

这里使用线性插值,让圆心逐渐靠近鼠标:

js 复制代码
const deltaX = pointerX - circleX;
const deltaY = pointerY - circleY;
const distance = Math.hypot(deltaX, deltaY);
const follow = distance < 50 ? 0.7 : 0.4;

circleX += deltaX * follow;
circleY += deltaY * follow;

其中:

  • pointerX、pointerY 是鼠标的真实位置;
  • circleX、circleY 是当前绘制位置;
  • 距离较远时使用 0.4,让圆圈平滑追赶;
  • 距离小于 50px 时使用 0.7,避免圆圈接近鼠标后仍然明显拖尾。

调整 follow 就可以改变跟随手感:数值越大,跟随越快;数值越小,惯性越明显。

四、实现淡入、缩放和淡出

进入标题时,透明度和缩放目标值设为 1;离开时,透明度变为 0,缩放目标值变为 0.5:

js 复制代码
const targetOpacity = active ? 1 : 0;
const targetScale = active ? 1 : 0.5;

opacity += (targetOpacity - opacity) * 0.24;
scale += (targetScale - scale) * 0.24;

绘制圆形时,将基础半径 32px 乘以缩放值:

js 复制代码
context.globalAlpha = opacity;
context.fillStyle = "#fff";
context.beginPath();
context.arc(circleX, circleY, 32 * scale, 0, Math.PI * 2);
context.fill();

因此,圆圈完全展开时直径约为 64px。离开标题后,它会在淡出的同时缩小到一半。

五、判断鼠标是否位于目标文字上

通过 closest() 判断当前事件目标或其父元素是否带有指定属性:

js 复制代码
active = Boolean(
  event.target.closest('[data-cursor="blend"]')
);

与直接判断 event.target === element 相比,closest() 对标题内部存在其他标签的情况更友好。

完整的事件监听如下:

js 复制代码
region.addEventListener("pointermove", (event) => {
  if (!finePointer.matches) return;

  pointerX = event.clientX;
  pointerY = event.clientY;
  active = Boolean(
    event.target.closest('[data-cursor="blend"]')
  );

  if (opacity <= 0.002) {
    circleX = pointerX;
    circleY = pointerY;
  }

  requestAnimation();
}, { passive: true });

第一次显示圆圈时,先把圆心设置到鼠标位置,可以防止圆圈从页面左上角飞入。

六、高分屏适配

只设置 Canvas 的 CSS 宽高,在 Retina 等高分屏上可能出现边缘模糊。需要根据设备像素比设置 Canvas 的实际像素尺寸:

js 复制代码
function resizeCanvas() {
  const dpr = Math.min(window.devicePixelRatio || 1, 2);

  canvas.width = Math.round(window.innerWidth * dpr);
  canvas.height = Math.round(window.innerHeight * dpr);
  context.setTransform(dpr, 0, 0, dpr, 0, 0);
}

这里把 DPR 最大限制为 2,可以在保证清晰度的同时控制绘制开销。

浏览器窗口尺寸变化时重新设置 Canvas:

js 复制代码
window.addEventListener("resize", resizeCanvas);
resizeCanvas();

七、触屏设备和减少动画设置

手机和平板没有真正的悬停指针,因此可以直接隐藏 Canvas:

css 复制代码
@media (hover: none),
       (pointer: coarse),
       (prefers-reduced-motion: reduce) {
  .cursor-canvas {
    display: none;
  }
}

JavaScript 中也进行一次检测:

js 复制代码
const finePointer = window.matchMedia(
  "(hover: hover) and (pointer: fine)"
);

这样既避免了触屏设备上的无效动画,也照顾了系统中开启"减少动态效果"的用户。

八、完整代码

下面的代码可以直接保存为 HTML 文件并在浏览器中打开:

html 复制代码
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Difference Cursor Demo</title>
  <style>
    * { box-sizing: border-box; }

    html,
    body {
      width: 100%;
      min-height: 100%;
      margin: 0;
    }

    body {
      overflow: hidden;
      font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
      background: #fafafa;
    }

    .hero {
      display: grid;
      min-height: 100vh;
      place-items: center;
      overflow: hidden;
      background:
        linear-gradient(rgba(46, 84, 128, 0.035) 1px, transparent 1px),
        linear-gradient(90deg, rgba(46, 84, 128, 0.035) 1px, transparent 1px),
        linear-gradient(180deg, #dceafb 0%, #f8fbff 76%, #fafafa 100%);
      background-size: 88px 88px, 88px 88px, auto;
    }

    .cursor-region {
      width: fit-content;
    }

    h1 {
      margin: 0;
      color: #152443;
      font-size: clamp(34px, 5vw, 64px);
      font-weight: 400;
      line-height: 1.55;
      letter-spacing: 0.4em;
      text-align: center;
      white-space: nowrap;
      opacity: 0.92;
    }

    [data-cursor="blend"] {
      display: inline-block;
    }

    .cursor-canvas {
      position: fixed;
      inset: 0;
      z-index: 9999;
      display: block;
      width: 100vw;
      height: 100vh;
      pointer-events: none;
      mix-blend-mode: difference;
    }

    @media (max-width: 520px) {
      h1 { letter-spacing: 0.18em; }
    }

    @media (hover: none),
           (pointer: coarse),
           (prefers-reduced-motion: reduce) {
      .cursor-canvas { display: none; }
    }
  </style>
</head>
<body>
  <main class="hero">
    <div class="cursor-region">
      <h1><span data-cursor="blend">探索未至之境</span></h1>
    </div>
  </main>

  <canvas class="cursor-canvas" aria-hidden="true"></canvas>

  <script>
    const region = document.querySelector(".cursor-region");
    const canvas = document.querySelector(".cursor-canvas");
    const context = canvas.getContext("2d");
    const finePointer = window.matchMedia(
      "(hover: hover) and (pointer: fine)"
    );

    let pointerX = 0;
    let pointerY = 0;
    let circleX = 0;
    let circleY = 0;
    let opacity = 0;
    let scale = 0.5;
    let active = false;
    let animationFrame = 0;

    function resizeCanvas() {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);

      canvas.width = Math.round(window.innerWidth * dpr);
      canvas.height = Math.round(window.innerHeight * dpr);
      context.setTransform(dpr, 0, 0, dpr, 0, 0);
    }

    function animate() {
      const deltaX = pointerX - circleX;
      const deltaY = pointerY - circleY;
      const distance = Math.hypot(deltaX, deltaY);
      const follow = distance < 50 ? 0.7 : 0.4;
      const targetOpacity = active ? 1 : 0;
      const targetScale = active ? 1 : 0.5;

      circleX += deltaX * follow;
      circleY += deltaY * follow;
      opacity += (targetOpacity - opacity) * 0.24;
      scale += (targetScale - scale) * 0.24;

      context.clearRect(0, 0, window.innerWidth, window.innerHeight);

      if (opacity > 0.002) {
        context.save();
        context.globalAlpha = opacity;
        context.fillStyle = "#fff";
        context.beginPath();
        context.arc(circleX, circleY, 32 * scale, 0, Math.PI * 2);
        context.fill();
        context.restore();
      }

      const moving =
        Math.abs(deltaX) + Math.abs(deltaY) > 0.1 ||
        Math.abs(targetOpacity - opacity) > 0.002 ||
        Math.abs(targetScale - scale) > 0.002;

      if (moving) {
        animationFrame = requestAnimationFrame(animate);
      } else {
        animationFrame = 0;
      }
    }

    function requestAnimation() {
      if (!animationFrame) {
        animationFrame = requestAnimationFrame(animate);
      }
    }

    function hideCircle() {
      active = false;
      requestAnimation();
    }

    region.addEventListener("pointermove", (event) => {
      if (!finePointer.matches) return;

      pointerX = event.clientX;
      pointerY = event.clientY;
      active = Boolean(
        event.target.closest('[data-cursor="blend"]')
      );

      if (opacity <= 0.002) {
        circleX = pointerX;
        circleY = pointerY;
      }

      requestAnimation();
    }, { passive: true });

    region.addEventListener("pointerleave", hideCircle, { passive: true });
    window.addEventListener("scroll", hideCircle, { passive: true });
    window.addEventListener("blur", hideCircle);
    window.addEventListener("resize", resizeCanvas);

    document.addEventListener("visibilitychange", () => {
      if (document.hidden) hideCircle();
    });

    resizeCanvas();
  </script>
</body>
</html>

九、可以继续调整的参数

主要视觉参数都集中在几行代码中:

js 复制代码
const follow = distance < 50 ? 0.7 : 0.4;
opacity += (targetOpacity - opacity) * 0.24;
scale += (targetScale - scale) * 0.24;
context.arc(circleX, circleY, 32 * scale, 0, Math.PI * 2);

参数含义如下:

参数 作用
50 切换跟随速度的距离阈值
0.7 鼠标距离较近时的跟随速度
0.4 鼠标距离较远时的跟随速度
0.24 淡入淡出及缩放缓动速度
32 圆圈完全展开时的半径
0.5 圆圈离开时的缩放比例

如果想让圆圈更柔和,可以降低 follow 和 0.24;如果希望响应更直接,则可以适当提高这些数值。

十、常见问题

1. 圆圈没有反色,只显示白色

检查 .cursor-canvas 是否设置了:

css 复制代码
mix-blend-mode: difference;

同时注意父元素不要创建会隔离混合模式的层叠上下文。例如,在某些布局中,父元素使用 isolation: isolate、滤镜或特殊合成属性,可能导致 Canvas 无法与目标背景混合。

2. 页面上的按钮无法点击

确认 Canvas 设置了:

css 复制代码
pointer-events: none;

否则位于最上层的 Canvas 会拦截页面交互。

3. 高分屏下圆圈边缘模糊

不要只设置 CSS 宽高,还要根据 devicePixelRatio 设置 Canvas 的 width 和 height 属性,并通过 setTransform() 缩放绘图坐标系。

4. 页面切换后动画仍然消耗性能

示例代码在动画完全静止后会停止调用 requestAnimationFrame,只有鼠标再次移动或状态改变时才会重新启动,比一直运行的动画循环更节省资源。

总结

这个效果的核心并不复杂:

  1. 使用全屏透明 Canvas 绘制白色圆形;
  2. 使用 mix-blend-mode: difference 对背景和文字进行反色;
  3. 使用插值计算圆心位置,形成惯性跟随;
  4. 使用透明度和缩放插值完成进入、离开动画;
  5. 使用 DPR 适配高分屏,并对触屏和减少动画设置进行降级。

掌握这个组合后,还可以继续扩展出方形遮罩、文字放大镜、图片反色跟随区域等交互效果。

相关推荐
驱动小百科3 小时前
Microsoft Visual C++ 2015安装失败如何修复 教你逐步处理
microsoft·vc++ 2015安装失败·visual c++ 2015·运行库安装失败·vc++ 2015安装错误
硅基手札3 小时前
【串口技术系列文档 04】UART协议深度解析
驱动开发·stm32·单片机·嵌入式硬件·mcu·计算机外设
海盗12344 小时前
微软技术日报 2026-10-08:Windows 给智能体立沙箱,Surface 换上 NVIDIA 芯
人工智能·windows·microsoft·机器人·aigc
开开心心_Every4 小时前
电脑OCR识别软件支持表格识别图片转Excel
java·开发语言·微信·计算机外设·ocr·intellij-idea·excel
鱼宵1 天前
Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用
人工智能·spring·microsoft·大模型·springai·chatclient
Experience-摆渡1 天前
微软开源 NVX:Agent 微虚拟机沙盒的硬件级隔离拆解
microsoft
雷焰财经1 天前
量子计算进入“工程化窗口”:当微软让DARPA直接测试量子系统,下一场计算革命正在从实验室走向产业
microsoft·量子计算
野蛮人6号2 天前
macbook连上外接显示器后,字体变小,如何设置成像原来本机的界面一样呢?
计算机外设
打工仔折腾 AI2 天前
从零写一个CAD 05:以鼠标为中心的滚轮缩放,招法能复用但顺序不能反
开发语言·后端·python·性能优化·计算机外设·swift·ai agent 实战