使用 Canvas 和 mix-blend-mode 实现跟随鼠标的反色圆圈
在一些强调视觉表现的官网中,我们经常能看到这样的交互:鼠标移动到标题上时,会出现一个带有惯性的深色圆圈;圆圈经过文字时,文字也会自动反色。
这个效果看起来像是给文字增加了遮罩,实际上可以用一个全屏 Canvas 配合 CSS 的 mix-blend-mode: difference 很轻量地实现。
本文将实现下面几个效果:
- 圆圈只在鼠标进入指定文字时出现;
- 圆圈带有平滑的惯性跟随效果;
- 圆圈经过背景和文字时自动反色;
- 离开文字后缩小并淡出;
- 支持高分屏,并在触屏设备上自动关闭。
一、实现思路
页面主要由三部分组成:
- 一个用于展示标题的区域;
- 一个覆盖整个浏览器窗口的透明
Canvas; - 一段监听鼠标位置并绘制圆形的 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,只有鼠标再次移动或状态改变时才会重新启动,比一直运行的动画循环更节省资源。
总结
这个效果的核心并不复杂:
- 使用全屏透明 Canvas 绘制白色圆形;
- 使用
mix-blend-mode: difference对背景和文字进行反色; - 使用插值计算圆心位置,形成惯性跟随;
- 使用透明度和缩放插值完成进入、离开动画;
- 使用 DPR 适配高分屏,并对触屏和减少动画设置进行降级。
掌握这个组合后,还可以继续扩展出方形遮罩、文字放大镜、图片反色跟随区域等交互效果。