You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

自定义弹簧鼠标光标:CSS变换顺序异常问题求助

自定义弹簧阻尼鼠标光标变换顺序异常排查

问题现象

我制作了一个带弹簧(spring)和阻尼(damping)效果的圆形自定义鼠标光标,支持以下功能:

  • 跟随鼠标移动
  • 根据鼠标速度实现拉伸挤压效果
  • 基于鼠标速度方向的旋转效果

所有功能单独使用均正常,但组合使用时出现两种异常:

  • 若变换顺序为circle.style.transform = ${translateElement} ${scaleElement} ${rotateElement};:功能可运行,但拉伸挤压是在旋转后应用于原坐标轴,视觉上始终上下挤压、左右拉伸,无法实现朝向鼠标移动方向拉伸的预期效果。
  • 若变换顺序改为circle.style.transform = ${translateElement} ${rotateElement} ${scaleElement};:圆形光标完全不移动也不缩放,且控制台无报错。

已确认旋转功能本身生效,问题出在第二种变换顺序的后续步骤中。

问题根源

  1. 语法错误导致transform失效:rotateElement的代码写法遗漏了闭合括号,rotate(${angle}deg应为rotate(${angle}deg),浏览器无法解析错误的transform语法,直接忽略了整个变换规则。
  2. 变换原点偏移:元素默认的transform-origin是左上角,旋转后再缩放会导致元素偏离目标位置,即便语法修复后也会出现视觉错位。
  3. 初始transform冲突:CSS中默认设置了transform: rotate(45deg);,会与JS中动态设置的transform叠加,导致变换逻辑混乱。

修复方案

1. 修正JS中的rotate语法错误

将rotateElement的定义改为:

const rotateElement = `rotate(${angle}deg)`;

2. 调整CSS的变换原点与初始transform

给.circle添加变换原点,并移除默认旋转:

.circle {
    --circle-size: 30px;

    position: fixed;
    top: calc(var(--circle-size) / 2 * -1);
    left: calc(var(--circle-size) / 2 * -1);
    pointer-events: none;
    box-sizing: border-box;
    width: var(--circle-size);
    height: var(--circle-size);
    background-color: #c1bb98;
    mix-blend-mode: difference;
    border-radius: 100%;
    transform-origin: center; /* 添加变换原点为中心 */
    /* 移除默认的rotate(45deg) */
    transition: opacity 0.15s ease-out;
}

3. 确认正确的变换顺序

使用translate → rotate → scale的顺序,这样缩放会基于旋转后的局部坐标轴,实现朝向鼠标移动方向拉伸的效果。

修复后的完整代码

JavaScript

const circle = document.querySelector('.circle');

const mousePos = { x: 0, y: 0 };
const prevMousePos = { x: 0, y: 0 };
let mouseSpeed = 0;

const circlePos = { x: 0, y: 0 };
const circleVelocity = { x: 0, y: 0 };
const spring = 0.025;
const damping = 0.845;

const maxSqueeze = 0.5;
const maxStretch = 1.5;
const maxMouseSpeed = 5;

let lowSpeedStartTime = 0;
let lowSpeedDuration = 0;
const lowSpeedThreshold = 0.5; // 0.5 seconds

document.addEventListener('mousemove', (e) => {
    const currentMousePos = { x: e.clientX, y: e.clientY };
    const dx = currentMousePos.x - prevMousePos.x;
    const dy = currentMousePos.y - prevMousePos.y;

    // Calculate the speed as the magnitude of the velocity vector
    mouseSpeed = Math.sqrt(dx * dx + dy * dy);

    // Update previous mouse position
    prevMousePos.x = currentMousePos.x;
    prevMousePos.y = currentMousePos.y;

    // Update the current mouse position
    mousePos.x = currentMousePos.x;
    mousePos.y = currentMousePos.y;
});

function animate() {
    const ax = (mousePos.x - circlePos.x) * spring;
    const ay = (mousePos.y - circlePos.y) * spring;

    circleVelocity.x += ax;
    circleVelocity.y += ay;
    circleVelocity.x *= damping;
    circleVelocity.y *= damping;

    circlePos.x += circleVelocity.x;
    circlePos.y += circleVelocity.y;

    // calculate rotation angle
    const angle = Math.atan2(circleVelocity.y, circleVelocity.x) * 180 / Math.PI;

    // Calculate the normalized mouse speed
    const normalizedSpeed = Math.min(mouseSpeed, maxMouseSpeed) / maxMouseSpeed;
    
    // Calculate stretch and squeeze factors
    const scaleFactor = 1 + (maxStretch - 1) * normalizedSpeed;
    const squeezeFactor = 1 - (1 - maxSqueeze) * normalizedSpeed;

    if (mouseSpeed <= 1) {
        if (lowSpeedStartTime === 0) {
            lowSpeedStartTime = performance.now();
        }
        lowSpeedDuration = (performance.now() - lowSpeedStartTime) / 1000; // in seconds
    } else {
        lowSpeedStartTime = 0;
        lowSpeedDuration = 0;
    }

    // Apply squeeze/stretch effect
    let scaleX = scaleFactor; // Stretch
    let scaleY = squeezeFactor; // Squeeze
    if (lowSpeedDuration > lowSpeedThreshold) {
        scaleX = 1.0;
        scaleY = 1.0;
    }

    const translateElement = `translate(${circlePos.x}px, ${circlePos.y}px)`;
    const rotateElement = `rotate(${angle}deg)`; // 修复语法错误
    const scaleElement = `scale(${scaleX}, ${scaleY})`;

    circle.style.transform = `${translateElement} ${rotateElement} ${scaleElement}`;

    requestAnimationFrame(animate);
}

animate();

document.addEventListener('mouseleave', () => {
    circle.style.opacity = '0'; // Make cursor disappear
});

document.addEventListener('mouseenter', () => {
    circle.style.opacity = '1'; // Make cursor reappear when mouse enters the document again
});

CSS

body {
    background-color: #1d1f22;
}

.circle {
    --circle-size: 30px;

    position: fixed;
    top: calc(var(--circle-size) / 2 * -1);
    left: calc(var(--circle-size) / 2 * -1);
    pointer-events: none;
    box-sizing: border-box;
    width: var(--circle-size);
    height: var(--circle-size);
    background-color: #c1bb98;
    mix-blend-mode: difference;
    border-radius: 100%;
    transform-origin: center;
    transition: opacity 0.15s ease-out;
}

HTML

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="style.css">
    <title>Document</title>
</head>
<body>
    
    <div class="circle"></div>
    <script src="script.js"></script>

</body>
</html>

内容的提问来源于stack exchange,提问作者Tughlok Siddiquee

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.18 12:07:06