编写一个交互式(并且令人满意的)游标:7 个简单步骤 + 2kb 代码

2025-06-07

编写一个交互式(并且令人满意的)游标:7 个简单步骤 + 2kb 代码

我最近制作了这个光标动画,人们似乎很喜欢它:)

这件作品不仅外观精美,而且相当简洁,仅占用 2KB 的 JS 代码。此外,这种方法非常通用,可以作为其他美图的模板。

因此它值得一步一步的指导!

我们走吧

步骤#1:设置

我们正在绘制<canvas>元素,我们需要<canvas>占据整个屏幕。



canvas {
    position: fixed;
    top: 0;
    left: 0;
}


Enter fullscreen mode Exit fullscreen mode


<canvas></canvas>


Enter fullscreen mode Exit fullscreen mode


setupCanvas();
window.addEventListener("resize", setupCanvas);

function setupCanvas() {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
}


Enter fullscreen mode Exit fullscreen mode

当然,我们需要跟踪光标的位置。



const pointer = {
    x: .5 * window.innerWidth,
    y: .5 * window.innerHeight,
}

window.addEventListener("click", e => {
    updateMousePosition(e.pageX, e.pageY);
});
window.addEventListener("mousemove", e => {
    updateMousePosition(e.pageX, e.pageY);
});
window.addEventListener("touchmove", e => {
    updateMousePosition(e.targetTouches[0].pageX, e.targetTouches[0].pageY);
});

function updateMousePosition(eX, eY) {
    pointer.x = eX;
    pointer.y = eY;
}


Enter fullscreen mode Exit fullscreen mode

步骤#2:动画循环

要看最简单的鼠标跟随动画,我们只需要使用 的方法循环重绘画布window.requestAnimationFrame(),并在每一步绘制以指针坐标为中心的圆圈即可。



const p = {x: 0, y: 0}; // coordinate to draw

update(0);

function update(t) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // copy cursor position
    p.x = poiner.x;
    p.y = poiner.y;
    // draw a dot
    ctx.beginPath();
    ctx.arc(p.x, p.y, 5, 0, 2 * Math.PI);
    ctx.fill();

    window.requestAnimationFrame(update);
}


Enter fullscreen mode Exit fullscreen mode

通过上面的代码,我们得到了一个跟随鼠标的黑色圆圈。

鼠标跟随

步骤#3:添加延迟

现在,圆圈正在尽可能快地跟随光标。让我们添加一个延迟,让圆点以某种弹性的方式追上目标位置。



const params = {
    // ...
    spring: .4
};
// p.x = poiner.x;
// p.y = poiner.y;
p.x += (pointer.x - p.x) * params.spring;
p.y += (pointer.y - p.y) * params.spring;

ctx.beginPath();
ctx.arc(p.x, p.y, 5, 0, 2 * Math.PI);
ctx.fill();


Enter fullscreen mode Exit fullscreen mode

spring参数用于确定点追赶光标位置的速度。较小的值(例如 ).1会使它跟随得非常慢,而 则spring = 1表示没有延迟。

鼠标延迟跟随

步骤#3:创建鼠标轨迹

让我们创建一个轨迹 - 点数据的数组,每个点都包含我们用来计算延迟的x/y坐标和dx/增量。dy



const params = {
    // ...
    pointsNumber: 30
};

// const p = {x: 0, y: 0};

const trail = new Array(params.pointsNumber);
for (let i = 0; i < params.pointsNumber; i++) {
    trail[i] = {
        x: poiner.x,
        y: poiner.y,
        dx: 0,
        dy: 0,
    }
}



Enter fullscreen mode Exit fullscreen mode

现在我们不再绘制单个点,而是绘制整条轨迹,每个点都试图追赶前一个点。第一个点追赶的是光标坐标(pointer),而这个第一个点的延迟时间更长——只是因为这样看起来更好 :)




function update(t) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    trail.forEach((p, pIdx) => {
        const prev = pIdx === 0 ? pointer : trail[pIdx - 1];
        const spring = pIdx === 0 ? .4 * params.spring : params.spring;

        p.dx = (prev.x - p.x) * spring;
        p.dy = (prev.y - p.y) * spring;

        p.x += p.dx;
        p.y += p.dy;

        ctx.beginPath();
        ctx.arc(p.x, p.y, 5, 0, 2 * Math.PI);
        ctx.fill();
    });

    window.requestAnimationFrame(update);
}


Enter fullscreen mode Exit fullscreen mode

老鼠踪迹

步骤#4:将点变成线

绘制折线比绘制点更容易。



trail.forEach((p, pIdx) => {
    const prev = pIdx === 0 ? pointer : trail[pIdx - 1];

    p.dx = (prev.x - p.x) * params.spring;
    p.dy = (prev.y - p.y) * params.spring;

    p.x += p.dx;
    p.y += p.dy;

    // ctx.beginPath();
    // ctx.arc(p.x, p.y, 5, 0, 2 * Math.PI);
    // ctx.fill();

    if (pIdx === 0) {
        // start the line on the first point
        ctx.beginPath();
        ctx.moveTo(p.x, p.y);
    } else {
        // continue with new line segment to the following one
        ctx.lineTo(p.x, p.y);
    }
});

// draw the thing
ctx.stroke();


Enter fullscreen mode Exit fullscreen mode

折线

步骤#5:积累速度

让光标动画看起来更美观的关键在于累积增量。让我们不仅使用dx/来表示到相邻位置的距离,还要累积这个距离。dy

为了防止增量值快速变得过大,我们还会在每一步中将dx/dy乘以新参数。friction



const params = {
    // ...
    friction: .5
};

...

// ...

// p.dx = (prev.x - p.x) * spring;
// p.dy = (prev.y - p.y) * spring;
p.dx += (prev.x - p.x) * spring;
p.dy += (prev.y - p.y) * spring;
p.dx *= params.friction;
p.dy *= params.friction;

// as before
p.x += p.dx;
p.y += p.dy;

// ...


Enter fullscreen mode Exit fullscreen mode

积累

步骤#6:平滑线条

动作完成了!让我们让描边看起来更好一些,并用贝塞尔曲线替换每条线段。



trail.forEach((p, pIdx) => {
    // calc p.x and p.y

    if (pIdx === 0) {
        ctx.beginPath();
        ctx.moveTo(p.x, p.y);
    // } else {
    //     ctx.lineTo(p.x, p.y);
    }
});

for (let i = 1; i < trail.length - 1; i++) {
    const xc = .5 * (trail[i].x + trail[i + 1].x);
    const yc = .5 * (trail[i].y + trail[i + 1].y);
    ctx.quadraticCurveTo(trail[i].x, trail[i].y, xc, yc);
}

ctx.stroke();



Enter fullscreen mode Exit fullscreen mode

光滑的!

平滑线

步骤#7:调整线条宽度

对于此演示,最后一步是将默认值lineWidth1px 替换为每段越来越小的动态值。



const params = {
    baseWidth: .9,
};
... 

for (let i = 1; i < trail.length - 1; i++) {
    // ...
    ctx.quadraticCurveTo(trail[i].x, trail[i].y, xc, yc);
    ctx.lineWidth = params.baseWidth * (params.pointsNumber - i);
}


Enter fullscreen mode Exit fullscreen mode

最终曲线

请参阅codepen上的源代码

文章来源:https://dev.to/uuuuuulala/coding-an-interactive-and-damn-satisfying-cursor-7-simple-steps-2kb-of-code-1c8b
PREV
提出好问题的艺术
NEXT
Docker 上的微服务和 RabbitMQ