说在前面
看腻了千篇一律的常规输入框,是时候给输入框加点趣味性了,今天我们来实现一个会和用户互动的柯基输入框:鼠标移动时柯基会扭头盯着你看,点击页面会眨眼,输入文字时还会弹动身体、弹出气泡反馈。
- 视线跟随:鼠标在页面移动,柯基的瞳孔会精准跟着鼠标转,聚焦输入框时还会盯着输入框看;
- 趣味眨眼:点击页面任意位置,柯基会俏皮眨眼,动画超自然;
- 打字反馈:在输入框打字时,柯基会轻轻弹动身体,还会根据输入字数弹出不同的气泡文案(比如 “在写啥呢...”“写了好多呀~”);
- 3D 视角:鼠标移动时柯基整体会有轻微 3D 旋转,视觉上更立体生动。
在线体验
在线预览
codePen
码上掘金
关键代码实现
1.用 SVG 画一只的柯基
前面有一篇文章介绍了怎么将图片转为 SVG,这里可以直接在找一张柯基的图片来将其转为 SVG,转换工具地址如下:
对转换工具实现感兴趣的同学可以查看这篇文章:mp.weixin.qq.com/s/c6qVOu_hT…
2.CSS 实现基础动画
转换成 SVG 之后我们需要将柯基的眼睛单独设为带 ID 的元素,方便做眨眼动画
- 眨眼动画:用@keyframes做垂直缩放(scaleY),模拟闭眼效果;
@keyframes wink-animation {
0%, 100% { transform: scaleY(1); } /* 正常状态 */
50% { transform: scaleY(0.2); } /* 闭眼状态:垂直缩放到20% */
}
.wink { animation: wink-animation 0.3s ease-in-out; }
- 打字弹动:给柯基容器加弹跳动画,输入文字时触发,增强互动感;
@keyframes bounce-typing {
0%, 100% { transform: translateY(0) scale(1); }
30% { transform: translateY(-6px) scale(1.05); } /* 向上弹+轻微放大 */
60% { transform: translateY(2px) scale(0.98); } /* 轻微回落+缩小 */
}
.dog-container.bounce { animation: bounce-typing 0.4s ease-out; }
- 气泡提示:用绝对定位做气泡样式,通过opacity控制显隐,加小三角伪元素模拟气泡尾巴。
.thought-bubble {
position: absolute;
opacity: 0; /* 默认隐藏 */
transform: translate(-50%, -100%) scale(0.8); /* 初始缩小+位移 */
transition: opacity 0.2s, transform 0.2s; /* 过渡动画 */
}
.thought-bubble.show {
opacity: 1; /* 显示 */
transform: translate(-50%, -100%) scale(1); /* 恢复正常大小 */
}
.thought-bubble::after {
content: "";
position: absolute;
left: 50%;
bottom: -8px;
margin-left: -6px;
border: 6px solid transparent;
border-top-color: #fff; /* 气泡尾巴的核心:透明边框+顶部白色 */
border-bottom: none;
}
3.JS 实现交互逻辑
(1)3D 视角旋转(柯基扭头)
// 计算鼠标偏离页面中心的比例,转换为旋转角度
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
// 旋转角度:鼠标在中心上方→rotateX为正(柯基低头),右侧→rotateY为正(柯基左转)
const rotateX = ((mouseY - centerY) / centerY) * -10;
const rotateY = ((mouseX - centerX) / centerX) * 10;
// 应用3D旋转
dogContainer.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
(2)确定瞳孔的目标位置
// 确定瞳孔要看向的目标点
let pointInSvg;
if (lookAtInput) {
// 输入框聚焦时,看向预设的输入框位置
pointInSvg = lookAtInputPoint;
} else {
// 否则,把鼠标的屏幕坐标转换为SVG内部坐标
const svgPoint = dogSvg.createSVGPoint();
svgPoint.x = mouseX;
svgPoint.y = mouseY;
// 坐标转换:屏幕坐标 → SVG内部坐标(解决SVG嵌套/缩放导致的错位)
pointInSvg = svgPoint.matrixTransform(dogSvg.getScreenCTM().inverse());
}
(3)计算瞳孔的目标偏移(以左眼为例)
// 计算左眼瞳孔的目标位置
// 目标点与眼球中心的偏移量
const deltaLeftX = pointInSvg.x - leftEyeCenter.x;
const deltaLeftY = pointInSvg.y - leftEyeCenter.y;
// 计算角度(Math.atan2:根据y/x算弧度,范围-π到π)
const angleLeft = Math.atan2(deltaLeftY, deltaLeftX);
// 计算距离(勾股定理)
const distanceLeft = Math.sqrt(deltaLeftX ** 2 + deltaLeftY ** 2);
// 目标位置:眼球中心 + 沿角度方向的偏移(不超过maxRadius)
const targetLeftX = leftEyeCenter.x + Math.cos(angleLeft) * Math.min(distanceLeft, maxRadius);
const targetLeftY = leftEyeCenter.y + Math.sin(angleLeft) * Math.min(distanceLeft, maxRadius);
- Math.atan2(dy, dx) :核心函数,返回从 x 轴到点 (dx, dy) 的弧度,用于确定瞳孔的移动方向;
- Math.min(distanceLeft, maxRadius) :限制偏移距离,避免瞳孔移出眼眶;
- Math.cos(angle)/Math.sin(angle) :将弧度转换为 x/y 方向的偏移量。
(4)缓动更新瞳孔位置
// 缓动更新瞳孔位置(避免瞬间移动,更自然)
currentPupilPos.left.x += (targetLeftX - currentPupilPos.left.x) * smoothingFactor;
currentPupilPos.left.y += (targetLeftY - currentPupilPos.left.y) * smoothingFactor;
// 右眼同理(代码略)
// 应用位置到SVG元素
leftPupil.setAttribute("transform", `translate(${currentPupilPos.left.x}, ${currentPupilPos.left.y})`);
rightPupil.setAttribute("transform", `translate(${currentPupilPos.right.x}, ${currentPupilPos.right.y})`);
// 循环执行动画(requestAnimationFrame:浏览器刷新频率同步,流畅不卡顿)
requestAnimationFrame(animate);
}
// 启动动画循环
animate();
-
缓动公式:当前位置 += (目标位置 - 当前位置) × 平滑因子
每次只移动 “剩余距离的一小部分”,比如剩余 10px,平滑因子 0.08 就移动 0.8px,距离越近移动越慢,最终无限接近目标位置,视觉上就是 “顺滑跟随”;
-
requestAnimationFrame:替代setInterval,让动画和浏览器刷新频率(60 帧 / 秒)同步,避免卡顿。
(5)输入框交互
监听输入框的 focus/blur/input 事件,实现 “聚焦看输入框、打字弹动 + 气泡提示”:
// 聚焦输入框:让柯基看向输入框
inputEl.addEventListener("focus", () => {
lookAtInput = true;
});
// 失焦:恢复看鼠标,隐藏气泡
inputEl.addEventListener("blur", () => {
lookAtInput = false;
thoughtBubble.classList.remove("show");
thoughtBubble.textContent = "";
});
// 输入文字:弹动+气泡提示
inputEl.addEventListener("input", () => {
const len = inputEl.value.length;
if (len > 0) {
// 根据字数切换文案
thoughtBubble.textContent = len >= 10 ? "写了好多呀~" : len >= 5 ? "汪!写得好~" : "在写啥呢...";
thoughtBubble.classList.add("show");
// 气泡1.8秒后自动隐藏
clearTimeout(bubbleHideTimer);
bubbleHideTimer = setTimeout(() => {
thoughtBubble.classList.remove("show");
}, 1800);
} else {
thoughtBubble.classList.remove("show");
}
// 弹动动画(防抖:避免快速输入时重复触发)
clearTimeout(bounceDebounce);
dogContainer.classList.add("bounce");
bounceDebounce = setTimeout(() => {
dogContainer.classList.remove("bounce");
}, 420); // 动画时长400ms,留20ms余量
});
(6)点击眨眼
window.addEventListener("click", () => {
// 避免重复触发(比如快速点击)
if (leftEyeGroup.classList.contains("wink")) return;
// 添加眨眼类
leftEyeGroup.classList.add("wink");
rightEyeGroup.classList.add("wink");
// 300ms后移除(和动画时长一致)
setTimeout(() => {
leftEyeGroup.classList.remove("wink");
rightEyeGroup.classList.remove("wink");
}, 300);
});
源码地址
gitee
github
- 🌟 觉得有帮助的可以点个 star~
- 🖊 有什么问题或错误可以指出,欢迎 pr~
- 📬 有什么想要实现的功能或想法可以联系我~
公众号
关注公众号『 前端也能这么有趣 』,获取更多有趣内容~
发送 加群 还能加入前端交流群,和大家一起讨论技术、分享经验,偶尔也能摸鱼聊天~
说在后面
🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。