水墨风鼠标效果实现

水墨风鼠标特效实现

从 0 到 1:实现一个顺滑的“墨水圈”光标特效

这篇文章带你基于现有 index.htmlinkCursor.js(另附 Vue 版 App.vue)实现一个轻量的“墨水圈”效果:鼠标移动时淡淡的墨圈扩散,按下鼠标时更浓、更大的墨滴涟漪。
示例

  • 核心思路:用 JS 在鼠标位置动态插入绝对定位的 div,用 CSS @keyframes 做扩散与淡出动画,动画结束后自动移除节点。
  • 两类波纹:移动时的轻微墨圈(ink),按下时的浓重墨滴(inkDrop)。
  • 性能要点:基于“抽样”技术降低插入频率,避免频繁 DOM 操作导致掉帧。

HTML文件(index.html)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Ink Cursor Demo</title>
    <style>
        /* Container to capture events and position ink circles */
        .container {
            position: relative;
            height: 100vh;
            /* cursor: none; */
        }

        .ink {
            position: absolute;
            pointer-events: none;
            z-index: 9999;
            opacity: 0.7;
            animation: growInk 2500ms;
        }

        .inkDrop{
            position: absolute;
            pointer-events: none;
            z-index: 9999;
            opacity: 0.7;
            animation: growInkDrop 3000ms;
        }

        @keyframes growInk {
            0% {
                transform: scale(1);
                opacity: 1;
            }

            100% {
                transform: scale(3);
                opacity: 0;
            }
        }

        @keyframes growInkDrop{
            0% {
                transform: scale(1);
                opacity: 1;
            }

            100% {
                transform: scale(6);
                opacity: 0;
            }
        }
    </style>
</head>
<body>
    <div id="ink-container" class="container"></div>

    <script type="module" src="./inkCursor.js"></script>
</body>
</html>

JS文件(inkCursor.js)

let inkId = 0;

const container = document.getElementById('ink-container');
const targetRoot = container ?? document.body;

function createInkCircle(x, y) {
	const batch = 7;
	inkId++;
	if (inkId % batch !== 0) return;

	const initialSize = 7 + Math.random() * 5;
	const duration = 2400;

	const el = document.createElement('div');
	el.className = 'ink';
	Object.assign(el.style, {
		left: `${x - initialSize / 2}px`,
		top: `${y - initialSize / 2}px`,
		width: `${initialSize}px`,
		height: `${initialSize}px`,
		borderRadius: '50%',
		position: 'absolute',
		backgroundColor: 'rgba(0, 0, 0, 0.4)'
	});

	targetRoot.appendChild(el);
	setTimeout(() => el.remove(), duration);
}

function createInkCircleDown(x, y) {
	const initialSize = 20 + Math.random() * 10;
	const duration = 1500;

	const el = document.createElement('div');
	el.className = 'inkDrop';
	Object.assign(el.style, {
		left: `${x - initialSize / 2}px`,
		top: `${y - initialSize / 2}px`,
		width: `${initialSize}px`,
		height: `${initialSize}px`,
		borderRadius: '50%',
		position: 'absolute',
		backgroundColor: 'rgba(0, 0, 0, 0.8)'
	});

	targetRoot.appendChild(el);
	setTimeout(() => el.remove(), duration);
}

function handleMouseMove(event) {
	createInkCircle(event.clientX, event.clientY);
}

function handleMouseDown(event) {
	createInkCircleDown(event.clientX, event.clientY);
}

function init() {
	const listenTarget = container ?? window;
	listenTarget.addEventListener('mousemove', handleMouseMove, { passive: true });
	listenTarget.addEventListener('mousedown', handleMouseDown, { passive: true });
}

init();

将两个文件复制并放置在同一级文件下后,便可以打开html查看效果(确保js文件命名正确)


页面骨架与资源引入

容器负责承载墨水圈,与脚本模块化引入:

<body>
    <div id="ink-container" class="container"></div>
    <script type="module" src="./inkCursor.js"></script>
</body>
  • 容器#ink-container 作为波纹的定位上下文(position: relative),也方便只在特定区域显示效果。
  • 模块化type="module" 让我们可以使用现代 JS 书写方式。

样式与动画:墨圈如何“活起来”

两类波纹共性:绝对定位、不可交互(pointer-events: none)、高层级、透明度动画。差异:扩散倍数和持续时间。

.ink {
    position: absolute;
    pointer-events: none;
    z-index: 9999;
    opacity: 0.7;
    animation: growInk 2500ms;
}

.inkDrop{
    position: absolute;
    pointer-events: none;
    z-index: 9999;
    opacity: 0.7;
    animation: growInkDrop 3000ms;
}

动画曲线:从 1 倍缩放到更大,同时透明度从 1 衰减到 0。

@keyframes growInk {
    0% {
        transform: scale(1);
        opacity: 1;
    }

    100% {
        transform: scale(3);
        opacity: 0;
    }
}

@keyframes growInkDrop{
    0% {
        transform: scale(1);
        opacity: 1;
    }

    100% {
        transform: scale(6);
        opacity: 0;
    }
}
  • 设计建议
    • 氛围感scale(3)scale(6) 搭配不同颜色透明度(见下文 JS)拉开层次。
    • 性能:尽量用 transformopacity 做动画,避免引发重排。

核心 JS:在鼠标处“滴墨”

1) 容器与事件委托
let inkId = 0;

const container = document.getElementById('ink-container');
const targetRoot = container ?? document.body;
  • 定位上下文:优先挂到 #ink-container,否则退回 document.body
  • 标识符:通过自增 inkId 给每个墨圈唯一 id(便于移除/调试)。

事件绑定与初始化

function init() {
	const listenTarget = container ?? window;
	listenTarget.addEventListener('mousemove', handleMouseMove, { passive: true });
	listenTarget.addEventListener('mousedown', handleMouseDown, { passive: true });
}

init();
  • passive: true:避免滚动阻塞等潜在性能问题。
  • listenTarget:优先监听容器,必要时监听 window 覆盖全局。
2) 鼠标移动:轻墨圈(批次抽样控制密度)
function createInkCircle(x, y) {
	const batch = 7;
	inkId++;
	if (inkId % batch !== 0) return;

	const initialSize = 7 + Math.random() * 5;
	const duration = 2400;

	const el = document.createElement('div');
	el.className = 'ink';
	Object.assign(el.style, {
		left: `${x - initialSize / 2}px`,
		top: `${y - initialSize / 2}px`,
		width: `${initialSize}px`,
		height: `${initialSize}px`,
		borderRadius: '50%',
		position: 'absolute',
		backgroundColor: 'rgba(0, 0, 0, 0.4)'
	});

	targetRoot.appendChild(el);
	setTimeout(() => el.remove(), duration);
}
  • 抽样控制inkId % 7 仅让每 7 次移动生成一次节点,显著减少 DOM 插入次数。
  • 随机尺寸7~12px 的初始尺寸 + 动画扩散,视觉更自然。
  • 定时清理:与 CSS 动画时长匹配,自动回收节点。

触发器:

function handleMouseMove(event) {
	createInkCircle(event.clientX, event.clientY);
}

function handleMouseDown(event) {
	createInkCircleDown(event.clientX, event.clientY);
}
3) 鼠标按下:浓墨滴
function createInkCircleDown(x, y) {
	const initialSize = 20 + Math.random() * 10;
	const duration = 1500;

	const el = document.createElement('div');
	el.className = 'inkDrop';
	Object.assign(el.style, {
		left: `${x - initialSize / 2}px`,
		top: `${y - initialSize / 2}px`,
		width: `${initialSize}px`,
		height: `${initialSize}px`,
		borderRadius: '50%',
		position: 'absolute',
		backgroundColor: 'rgba(0, 0, 0, 0.8)'
	});

	targetRoot.appendChild(el);
	setTimeout(() => el.remove(), duration);
}
  • 视觉对比:更大初始尺寸、更高不透明、扩散更剧烈(CSS scale(6))→ 有“按压反馈”的质感。

性能与体验优化要点

  • 抽样节流:移动事件高频触发,基于批次抽样是足够稳定好用的策略;也可切换到 requestAnimationFrame 结合“上一帧是否已生成”标记进一步平滑。
  • 动画时长对齐setTimeout 的时长需与 CSS 动画时长一致(或略长 50ms),避免提前删除或残留。
  • 定位上下文:容器 position: relative 保证绝对定位的 div 正确落点;若要全屏效果可用 body
  • 可访问性:保持 pointer-events: none,确保不影响页面交互。

可配置维度(快速调出不同风格)

  • 颜色与透明度:更柔和可用 rgba(0,0,0,0.25);品牌色则替换为主题色。
  • 密度:调整 batch(比如 5 更密,10 更稀)。
  • 大小initialSize 区间配合 CSS scale 影响整体氛围。
  • 区域限制:仅在指定容器内生效,避免干扰全局。

Vue 版本(结尾附上代码)

如果你在 Vue 项目内,需要响应式地管理“墨圈列表”,App.vue 展示了一个等价实现:

模板层:监听事件并按 v-for 渲染出墨圈节点。

<template>
  <div @mousemove="handleMouseMove" @mousedown="handleMouseDown" class="container">
    <router-view></router-view>
    <div
      v-for="(ink, index) in inks"
      :key="ink.id"
      :style="ink.style"
      :class="ink.class"
      >
    </div> 
  </div>
</template>

逻辑层:和原生 JS 一致,只是把“创建/删除”转为操作 inks 数组。

const createInkCircle = (x: number, y: number) => {
    const batch = 7;
    inkId++;
    if(inkId%batch!=0)return;
  const initialSize = 7 + Math.random() * 5
  const duration = 2400

  const newInk = {
    style: {
      left: `${x - initialSize / 2}px`,
      top: `${y - initialSize / 2}px`,
      width: `${initialSize}px`,
      height: `${initialSize}px`,
      borderRadius: '50%',
      position: 'absolute',
      backgroundColor: 'rgba(0, 0, 0, 0.4)',
    },
    id: inkId,
    class: 'ink'
  }

  inks.value.push(newInk)

  setTimeout(() => {
    inks.value = inks.value.filter((ink) => ink.id !== newInk.id)
  }, duration)
}

样式层同样沿用 @keyframes,与 index.html 中一致:

.ink {
    position: absolute;
    pointer-events: none;
    z-index: 9999;
    opacity: 0.7;
    animation: growInk 2500ms;
}

.inkDrop{
    position: absolute;
    pointer-events: none;
    z-index: 9999;
    opacity: 0.7;
    animation: growInkDrop 3000ms;
}

最后附上Vue版本源码

<template>
  <div @mousemove="handleMouseMove" @mousedown="handleMouseDown" class="container">
    <router-view></router-view>
    <div
      v-for="(ink, index) in inks"
      :key="ink.id"
      :style="ink.style"
      :class="ink.class"
      >
    </div> 
  </div>
</template>

<script lang="ts" setup>
import { ref, onMounted } from 'vue'

// 墨水圈数据
const inks = ref<{ style: Record<string, string | number>; id: number ;class:string}[]>([])

let inkId = 0 // 用来区分每个墨水圈的唯一标识符

const handleMouseMove = (event: MouseEvent) => {
  createInkCircle(event.clientX, event.clientY)
}

const handleMouseDown = (event: MouseEvent) => {
  createInkCircleDown(event.clientX, event.clientY)
}

const createInkCircle = (x: number, y: number) => {
    const batch = 7;
    inkId++;
    if(inkId%batch!=0)return;
  // 墨水圈初始大小
  const initialSize = 7 + Math.random() * 5
  const duration = 2400 // 动画时长,单位:毫秒

  const newInk = {
    style: {
      left: `${x - initialSize / 2}px`,
      top: `${y - initialSize / 2}px`,
      width: `${initialSize}px`,
      height: `${initialSize}px`,
      borderRadius: '50%',
      position: 'absolute',
      backgroundColor: 'rgba(0, 0, 0, 0.4)', // 墨水圈颜色
    //   animation: `growInk 700ms ease-out`, // 使用新的growInk动画
    },
    id: inkId, // 增加唯一标识符
    class: 'ink'
  }

  // 增加新的墨水圈
  inks.value.push(newInk)

  // 墨水圈动画结束后移除
  setTimeout(() => {
    inks.value = inks.value.filter((ink) => ink.id !== newInk.id)
  }, duration)
}

const createInkCircleDown = (x: number, y: number) => {
  // 墨水圈初始大小
  const initialSize = 20 + Math.random() * 10
  const duration = 1500 // 动画时长,单位:毫秒

  const newInk = {
    style: {
      left: `${x - initialSize / 2}px`,
      top: `${y - initialSize / 2}px`,
      width: `${initialSize}px`,
      height: `${initialSize}px`,
      borderRadius: '50%',
      position: 'absolute',
      backgroundColor: 'rgba(0, 0, 0, 0.8)', // 墨水圈颜色
    },
    id: inkId++, // 增加唯一标识符
    class: 'inkDrop'
  }

  // 增加新的墨水圈
  inks.value.push(newInk)

  // 墨水圈动画结束后移除
  setTimeout(() => {
    inks.value = inks.value.filter((ink) => ink.id !== newInk.id)
  }, duration)
}

onMounted(() => {
  // 页面加载后清除所有墨水圈
  inks.value = []
})

</script>

<style scoped>
.container {
  position: relative;
  height: 100vh;
  /* cursor: none; 隐藏默认鼠标 */
}

.ink {
    position: absolute;
    pointer-events: none;
    z-index: 9999;
    opacity: 0.7;
    animation: growInk 2500ms;
}

.inkDrop{
    position: absolute;
    pointer-events: none;
    z-index: 9999;
    opacity: 0.7;
    animation: growInkDrop 3000ms;
}

/* 定义墨水圈的动画 */
@keyframes growInk {
    0% {
        transform: scale(1);
        opacity: 1;
    }

    100% {
        transform: scale(3);
        opacity: 0;
    }
}

@keyframes growInkDrop{
    0% {
        transform: scale(1);
        opacity: 1;
    }

    100% {
        transform: scale(6);
        opacity: 0;
    }
}
</style>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值