微交互是界面里最不起眼、却最决定“手感”的那一部分。用户不会记得你的导航栏有几级菜单,但一定会记得按钮按下去有没有回应、链接悬停时下划线是从哪边滑出来的、输入框获得焦点时标签有没有轻轻上浮。这些加起来不到 200 毫秒的细节,构成了一个产品“精致”与“粗糙”的分水岭。微交互的难点从来不是“做不出来”,而是“做得刚刚好”。本篇 45 分钟深度长文,从状态伪类讲到 hover 的三重陷阱,从按钮的十种范式讲到 3D 倾斜、磁吸、光标跟随、涟漪反馈,再落到时长标尺、无障碍与性能代价,配合多个可以亲手拨动的交互实验台,帮你把“加个 hover”这件事,做成一套有章法、可复用、不伤性能的工程实践。
微交互的本质:一次反馈只值 100 毫秒
微交互(Microinteraction)这个概念由 Dan Saffer 在《微交互:细节设计成就卓越产品》中系统提出。他把任何一个微小的交互拆成四个部分:
需要强调的是:反馈永远是为规则服务的。如果规则本身不合理——比如点击后没有任何提示地干等 8 秒——再精致的动画也救不回来。CSS 能做的,只是把已经合理的规则,表达得更清楚、更舒服。
反馈的延迟阈值
在写第一行 transition 之前,请先记住下面这组数字。它们决定了你的动画该有多快:
| 延迟 | 用户感受 | 适用场景 |
|---|---|---|
0 ~ 100ms |
感觉“是我自己造成的”,即时、直接 | 按钮按下、开关切换、勾选 |
100 ~ 300ms |
仍然自然,但已能察觉“有点反应时间” | 悬停抬升、下拉展开、卡片倾斜 |
300ms ~ 1s |
明显有延迟,需要给加载指示 | 模态框、抽屉、页面级转场 |
| 超过 1s | 用户开始怀疑“是不是没点上” | 必须配骨架屏或进度条 |
所以,悬停效果的最佳时长是 120~250ms,点击反馈是 80~150ms。超过 300ms 的悬停动画,在熟练用户手里就会变成累赘——他们移动鼠标的速度远比动画快,动画还没播完,指针已经跑到下一个元素上了。
悬停是可逆的,这一点决定了技术选型
和页面入场动画不同,悬停随时可能被中途打断:用户扫过就走,或者快速来回抖动鼠标。这意味着:
- 优先使用
transition而不是animation:transition天然支持中断,会从当前状态平滑过渡到新状态;animation被打断时往往要从头播。 - 不要在
:hover里做“必须播完”的逻辑:比如把元素移到屏幕外再移回来。 - 动画的中间态也要好看:因为用户随时可能把鼠标停在半路。
.card {
transition: transform 0.18s ease-out;
}
.card:hover {
transform: translateY(-4px);
transition: transform 0.28s cubic-bezier(0.2, 0, 0, 1);
}
/* ❌ 不可中断:animation 会重新开始 */
.bad:hover {
animation: lift 0.3s forwards;
}
/* 上面的写法还可以做出“进入慢、离开快”的层次感 */
/* 基础态(离开)0.18s,hover 态(进入)0.28s */
状态伪类全解::hover 只是其中一员
很多人写微交互时只用 :hover,这是远远不够的。一个完整的交互状态机至少包含下面这些伪类,它们共同决定了一个控件在“被指向、被聚焦、被按下、被禁用”时分别长什么样。
| 伪类 | 触发条件 | 典型用途 |
|---|---|---|
:hover |
指针悬停在元素上 | 抬升、变色、显示次级信息 |
:focus |
元素获得焦点(点击或 Tab) | 兼容老浏览器,但会误伤鼠标点击 |
:focus-visible |
浏览器判断“需要显示焦点”时 | 键盘导航时才显示焦点环,首选 |
:focus-within |
自身或后代获得焦点 | 整张表单卡片的高亮 |
:active |
元素被按下(鼠标或触摸) | 按下缩小的即时反馈 |
:checked |
复选框 / 单选框被选中 | 自定义开关与选择器 |
:disabled |
元素被禁用 | 降低对比度、取消指针样式 |
:has() |
内部包含匹配某选择器的元素 | 父元素根据子元素状态做出反应 |
:target |
URL 片段与该元素 id 匹配 | 锚点跳转后的高亮提示 |
LVHA 顺序:一个不能乱的铁律
链接的四个状态伪类必须按固定顺序书写,否则后写的会覆盖先写的。助记口诀是 LoVe HAte:
a:link { color: #2563eb; }
a:visited { color: #7c3aed; }
a:hover { color: #1d4ed8; }
a:active { color: #1e40af; }
/* ❌ 错误顺序:hover 会被 link 覆盖 */
a:hover { color: #1d4ed8; }
a:link { color: #2563eb; }
/* 原因:两者优先级相同(0,1,1),后者胜出 */
为什么应该用 :focus-visible 而不是 :focus
:focus 的问题是:鼠标点击按钮时,它同样会被激活。于是用户点一下按钮,就出现一个刺眼的焦点环——视觉上非常“廉价”。:focus-visible 让浏览器替我们判断:如果这次聚焦是由键盘操作(Tab 键)引起的,才显示焦点样式;鼠标点击则显示 :hover / :active 的样式。
.btn {
outline: none;
transition: box-shadow 0.2s ease, transform 0.15s ease;
}
.btn:focus-visible {
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.4);
border-radius: 10px;
}
/* ❌ 彻底移除 outline 是严重的无障碍问题 */
.btn:focus { outline: none; }
/* ✅ 用 :focus-within 高亮整张表单卡片 */
.form-card {
transition: border-color 0.25s ease, box-shadow 0.25s ease;
}
.form-card:focus-within {
border-color: #2563eb;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1);
}
/* ✅ 用 :has() 让父元素感知子元素的悬停 */
.card:has(.btn:hover) {
box-shadow: 0 12px 30px -12px rgba(15, 23, 42, 0.3);
}
顺序问题:当 hover 与 focus-visible 同时存在
一个按钮可能同时处于 :hover 和 :focus-visible 状态。这时谁的样式生效,取决于书写顺序与优先级。通常的约定是:焦点环永远最外层优先显示,因为它承载着“当前键盘焦点在哪”的信息,不能因为鼠标恰好悬停而被隐藏。
.btn:hover { transform: translateY(-2px); }
.btn:active { transform: translateY(0) scale(0.98); }
.btn:focus-visible { box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.45); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
hover 的三重陷阱:触屏、抖动与误触
:hover 是微交互里用得最多、也最容易翻车的伪类。它有三个经典陷阱,每一个都能让精心设计的交互变成灾难。
陷阱一:触屏设备上的“粘滞 hover”
在触摸屏上,手指点一下按钮后,:hover 状态会一直保持,直到用户点击别处。结果就是:按钮变成“按下后卡住”的诡异状态;更糟的是,如果 hover 会让元素位移或展开子菜单,这个位移会一直存在,把布局搞得一团糟。
很多人的第一反应是用宽度来兜底:
@media (max-width: 768px) {
.card:hover { transform: none; }
}
/* 平板横屏宽 1024px,依然是触屏 */
/* 桌面窄窗口 700px,依然有鼠标 */
/* ✅ 正确:检测输入设备的“能力” */
@media (hover: hover) and (pointer: fine) {
.card:hover {
transform: translateY(-4px);
box-shadow: 0 14px 30px -12px rgba(15, 23, 42, 0.3);
}
}
/* 给触屏设备准备另一套反馈 */
@media (hover: none) {
.card:active {
transform: scale(0.98);
transition: transform 0.1s ease;
}
}
注意:@media (hover: hover) 询问的是设备的“主要输入机制”,而不是“此刻是否有鼠标”。一台带触摸屏的笔记本,可能同时满足 hover: hover 与 pointer: fine,这正是我们想要的——它确实支持鼠标悬停。
陷阱二:抖动(Hover Flicker)
抖动是悬停效果里最经典、最令人抓狂的 bug。它的成因很简单:悬停导致元素移出了指针所在的位置,于是 :hover 失效,元素弹回去,指针又重新落在元素上,:hover 再次生效……如此循环,元素开始疯狂闪烁。
.card {
transition: transform 0.3s ease;
}
.card:hover {
transform: translateY(-20px); /* 幅度太大 */
}
/* ✅ 方案一:减小位移幅度 */
.card:hover {
transform: translateY(-4px);
}
/* ✅ 方案二:用伪元素扩大命中区域 */
.card {
position: relative;
}
.card::after {
content: '';
position: absolute;
inset: -12px; /* 向四周外扩 12px */
}
/* ✅ 方案三:把位移交给内部元素 */
.card:hover .card-inner {
transform: translateY(-8px);
}
/* 外层不动,命中区域自然稳定 */
:hover 里改变 margin、padding、width、height,或者用超过 10px 的 translate 位移。
陷阱三:误触与注意力分散
鼠标在页面上移动时,会不经意扫过大量元素。如果每一个元素都在悬停时播放一段花哨的动画,整页就会像圣诞树一样闪个不停,用户的注意力被反复打断。
- 克制幅度:2~6px 的位移、1.02 倍的缩放,已经足够被感知。
- 加一点延迟:给非核心元素的悬停效果加
60~120ms的transition-delay,只有真正停留的指针才会触发。 - 只强调一个属性:不要同时变色、位移、放大、加阴影、旋转。选一到两个就够了。
- 关键操作不加延迟:按钮、链接这类用户明确指向的元素,必须即时响应。
.gallery-item {
transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1);
transition-delay: 0s; /* 离开时立即回位 */
}
.gallery-item:hover {
transform: scale(1.03);
transition-delay: 0.08s; /* 进入时稍等 80ms */
}
/* 但核心按钮不加延迟 */
.primary-btn {
transition: transform 0.15s ease, background-color 0.15s ease;
}
过渡的四大件:property、duration、timing-function、delay
微交互的绝大部分效果,最终都落在 transition 这一条声明上。它由四个部分组成,每一个都值得单独推敲。
1. transition-property:不要写 all
transition: all 0.3s 是新手最爱的写法,也是性能与可维护性的双重灾难。它会让浏览器监听元素上所有可动画属性的变化——包括那些你根本不想动的。当你后来给这个元素加了 color 或 border-radius 的改动时,它会莫名其妙地开始过渡。
.card { transition: all 0.3s ease; }
/* ✅ 明确列出要过渡的属性 */
.card {
transition:
transform 0.28s cubic-bezier(0.2, 0, 0, 1),
box-shadow 0.28s cubic-bezier(0.2, 0, 0, 1),
border-color 0.2s ease;
}
2. transition-duration:一切手感的来源
时长决定了交互的“重量感”。同样是按钮抬升,0.1s 显得轻快、0.3s 显得稳重、0.6s 就显得拖沓。经验值如下:
- 80~120ms:按下、勾选、开关等“即时反馈”
- 150~200ms:颜色变化、边框变化、下划线
- 200~300ms:位移、缩放、阴影
- 300~400ms:面板展开、卡片翻转
3. transition-timing-function:缓动的语义
缓动函数决定动画的“物理感”。对于微交互,记住三条规则基本够用:
- 元素进入(出现在视野 / 被指向):用
ease-out或cubic-bezier(0, 0, 0.2, 1),起步快、结尾慢,感觉“轻快”。 - 元素离开:用
ease-in或cubic-bezier(0.4, 0, 1, 1),起步慢、结尾快,感觉“干脆”。 - 位置移动:用
ease-in-out或cubic-bezier(0.2, 0, 0, 1),两端平滑。
:root {
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
--ease-accelerate: cubic-bezier(0.4, 0, 1, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
--ease-spring 的第二个参数大于 1,会产生“冲过头再回弹”的弹性手感,非常适合开关、勾选、点赞这类需要“情绪价值”的微交互。
4. transition-delay:最容易被忽视的第四件
延迟可以用来做两件事:
- 过滤误触:给非关键元素加 60~120ms 的进入延迟。
- 制造层次:卡片抬起 50ms 后,阴影再跟上,视觉上更有“重量”。
.card {
transition:
transform 0.25s var(--ease-standard),
box-shadow 0.25s var(--ease-standard) 0.05s;
}
可中断性:transition 的隐藏优势
前面提过,transition 天然支持中断。但还有一层更细的细节:浏览器在中断时会从“当前计算值”开始过渡,而不是从起始值重新开始。这意味着如果用户在动画播到 60% 时移开鼠标,元素会从 60% 的位置平滑回退,而不是先跳到 100% 再回来。
这个特性让 transition 天生适合悬停场景。而 animation 被打断时会重新从 0% 开始播放,视觉上就会出现明显的“跳变”。
按钮微交互的十种经典范式
按钮是微交互最密集的地方。下面这些范式几乎覆盖了 90% 的实际需求,全部只用 CSS 实现,且都只动画 transform、opacity、color、box-shadow 这些廉价属性。把鼠标放上去试试。
① 填充滑入(Fill Slide)
用一个伪元素作为背景,初始 scaleX(0),悬停时 scaleX(1)。关键在于 transform-origin 的选择:从左边展开、从中间展开、从底部升起,给人的感觉完全不同。
position: relative;
overflow: hidden;
isolation: isolate;
transition: color 0.3s ease, border-color 0.3s ease;
}
.btn-fill::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: #2563eb;
transform: scaleX(0);
transform-origin: left center;
transition: transform 0.35s cubic-bezier(0.2, 0, 0, 1);
}
.btn-fill:hover { color: #fff; }
.btn-fill:hover::before { transform: scaleX(1); }
注意 isolation: isolate 这一行:它让按钮形成独立的层叠上下文,伪元素的 z-index: -1 才会被正确地绘制在按钮背景之上、文字之下。
② 抬升浮起(Lift)
最小幅度的位移配上阴影的同步变化,就能产生“卡片被拿起来”的错觉。位移控制在 -2px ~ -6px,阴影的模糊半径同步放大。
transition:
transform 0.25s cubic-bezier(0.2, 0, 0, 1),
box-shadow 0.25s cubic-bezier(0.2, 0, 0, 1),
border-color 0.25s ease;
}
.btn-lift:hover {
transform: translateY(-4px);
box-shadow: 0 12px 22px -8px rgba(37, 99, 235, 0.45);
}
③ 光泽扫过(Shine)
用一个带倾斜角度的半透明白色渐变条,从左侧扫到右侧。它不改变任何布局,只改变 left 或 transform。推荐用 transform: translateX() 来实现,性能更好。
content: '';
position: absolute;
top: 0;
left: 0;
width: 55%;
height: 100%;
background: linear-gradient(100deg, transparent, rgba(255,255,255,0.7), transparent);
transform: translateX(-200%) skewX(-22deg);
transition: transform 0.6s cubic-bezier(0.2, 0, 0, 1);
pointer-events: none;
}
.btn-shine:hover::after {
transform: translateX(320%) skewX(-22deg);
}
④ 边框描边(Border Draw)
用两个伪元素分别绘制底部横线与右侧竖线,悬停时沿对角线方向依次展开。这种效果比单纯变色更有“绘制感”,适合用在次要按钮上。
content: '';
position: absolute;
left: 0; bottom: 0;
width: 100%; height: 2px;
background: #2563eb;
transform: scaleX(0);
transform-origin: left center;
transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1);
}
.btn-outline::after {
content: '';
position: absolute;
right: 0; top: 0;
width: 2px; height: 100%;
background: #2563eb;
transform: scaleY(0);
transform-origin: center top;
transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1) 0.12s;
}
.btn-outline:hover::before { transform: scaleX(1); }
.btn-outline:hover::after { transform: scaleY(1); }
⑤ 点击涟漪(Ripple)
涟漪效果需要知道点击位置,因此必须借助一点 JavaScript。核心思路是:在点击坐标处插入一个圆形元素,用 transform: scale() 从 0 放大到 1,同时透明度归零,动画结束后移除该元素。
btn.addEventListener('click', (e) => {
const rect = btn.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const span = document.createElement('span');
span.className = 'ripple';
span.style.width = span.style.height = size + 'px';
span.style.left = (e.clientX - rect.left - size / 2) + 'px';
span.style.top = (e.clientY - rect.top - size / 2) + 'px';
btn.appendChild(span);
setTimeout(() => span.remove(), 650);
});
注意:涟漪元素必须在动画结束后被移除,否则每次点击都会在 DOM 里堆积一个不可见的节点。长时间使用下来,这类“看不见的垃圾”是内存泄漏的常见来源。
⑥ 底色加深与文字位移
最朴素的悬停效果,也是最不应该被忽视的。把一个按钮的背景色从 #2563eb 加深到 #1d4ed8,同时让图标向右移动 2px,就足以传达“可点击”的信号。它几乎零成本,且永远不会出错。
background: #2563eb;
transition: background-color 0.2s ease;
}
.btn-basic:hover { background: #1d4ed8; }
.btn-basic i {
transition: transform 0.2s cubic-bezier(0.2, 0, 0, 1);
}
.btn-basic:hover i { transform: translateX(3px); }
⑦ 到⑩:更多值得掌握的范式
- 图标旋转:悬停时让刷新图标旋转 180°——用
transform: rotate()配合transition,比animation更可控。 - 文字上滑切换:把两行文字叠在一起,悬停时整体上移一半高度,实现“文案切换”的效果。
- 阴影呼吸:用
box-shadow的扩散半径变化模拟“呼吸”,适合提示性按钮。 - 按下缩小:
:active时transform: scale(0.96),给用户“按到了”的触觉暗示,这是移动端最重要的反馈之一。
.btn:active {
transform: scale(0.96);
transition-duration: 0.08s;
}
/* 图标旋转 */
.btn .icon {
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.btn:hover .icon { transform: rotate(180deg); }
卡片的悬浮语言:抬升、倾斜与光泽
卡片是信息密度最高的容器,它的悬停反馈需要同时传达三件事:“我在这里”“我可以点”“我浮在你上面”。下面这张卡片会跟随鼠标产生 3D 倾斜,并在指针位置产生高光。
悬浮倾斜卡片
把鼠标移到卡片上并缓慢移动,观察 rotateX / rotateY 与高光位置如何随指针变化。
3D 倾斜的原理
核心只有三步:
- 父元素设置
perspective,给子元素一个“景深”。 - 监听
mousemove,计算指针在卡片内的相对坐标(px, py),取值范围 0~1。 - 把
px映射为rotateY,把py映射为rotateX,角度通常控制在±10deg以内。
card.addEventListener('mousemove', (e) => {
const rect = card.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width; /* 0 ~ 1 */
const py = (e.clientY - rect.top) / rect.height;
const rotateX = (0.5 - py) * 14; /* 上下倾斜 */
const rotateY = (px - 0.5) * 16; /* 左右倾斜 */
card.style.transform =
`perspective(900px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
});
card.addEventListener('mouseleave', () => {
card.style.transform = '';
});
高光(Glare)的同步移动
倾斜之外,再叠一层跟随指针的径向渐变,就会产生“玻璃反光”的质感。实现方式是把指针的相对位置写进 CSS 变量的 background 里:
`radial-gradient(circle at ${px * 100}% ${py * 100}%, rgba(255,255,255,0.9), rgba(255,255,255,0) 55%)`;
glare.style.opacity = '1';
/* 离开时淡出高光 */
card.addEventListener('mouseleave', () => {
glare.style.opacity = '0';
});
性能提醒:不要每帧写 style
上面的 mousemove 回调会在指针移动时高频触发,某些鼠标每秒能触发 500 次以上。直接在里面写 style.transform 会导致大量样式重算。更稳妥的做法是用 requestAnimationFrame 做节流:
let nextX = 0, nextY = 0;
card.addEventListener('mousemove', (e) => {
const rect = card.getBoundingClientRect();
nextX = ((e.clientY - rect.top) / rect.height - 0.5) * -14;
nextY = ((e.clientX - rect.left) / rect.width - 0.5) * 16;
if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
card.style.transform =
`perspective(900px) rotateX(${nextX}deg) rotateY(${nextY}deg)`;
ticking = false;
});
}
});
另一个细节:getBoundingClientRect() 是强制同步布局的读取操作。在 mousemove 里每次调用都读一次,如果页面上还有其他写操作,就会引发 Layout Thrashing。更好的做法是在 mouseenter 时读一次缓存起来,后续直接用缓存值。
mousemove 里同时读取 getBoundingClientRect() 并写入 style,而且不做节流。快速移动鼠标时,主线程会被打满。
will-change: transform。
磁吸与光标跟随:让元素“看向”用户
磁吸(Magnetic)是近年来高端站点里非常流行的一种微交互:当指针靠近按钮时,按钮会向指针方向轻微偏移,仿佛被吸引;指针离开后,它又平滑归位。它传达的是一种“这个界面在回应我”的错觉。
磁吸的实现思路
核心是计算指针到按钮中心的向量,然后按这个向量的比例给按钮一个位移。距离越近,偏移越大;超过某个半径,偏移归零。
const btn = document.querySelector('.magnet-btn');
const RADIUS = 140; /* 磁吸半径 */
const STRENGTH = 0.35; /* 磁吸强度 */
stage.addEventListener('mousemove', (e) => {
const rect = btn.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = e.clientX - cx;
const dy = e.clientY - cy;
const dist = Math.hypot(dx, dy);
if (dist > RADIUS) {
btn.style.transform = '';
return;
}
const falloff = 1 - dist / RADIUS; /* 距离越近越强 */
btn.style.transform =
`translate(${dx * falloff * STRENGTH}px, ${dy * falloff * STRENGTH}px)`;
});
stage.addEventListener('mouseleave', () => {
btn.style.transform = '';
});
关键参数是 falloff——它让偏移量随距离衰减,而不是简单地“在半径内就固定偏移”。没有它,按钮会在进入半径的瞬间突然跳一下,非常生硬。
光标跟随:把指针变成设计的一部分
在深色区域里,一个跟随指针的柔和光晕能让整个界面“活”起来。它的实现非常轻量,只要一个绝对定位的圆形元素,位置由 mousemove 更新。
把鼠标移进来
const dot = document.querySelector('.cursor-dot');
stage.addEventListener('mousemove', (e) => {
const rect = stage.getBoundingClientRect();
dot.style.left = (e.clientX - rect.left) + 'px';
dot.style.top = (e.clientY - rect.top) + 'px';
dot.style.opacity = '1';
});
stage.addEventListener('mouseleave', () => {
dot.style.opacity = '0';
});
.cursor-dot {
position: absolute;
top: 0;
left: 0;
width: 180px;
height: 180px;
margin: -90px 0 0 -90px;
border-radius: 50%;
background: radial-gradient(circle, rgba(96,165,250,0.35), rgba(96,165,250,0) 68%);
pointer-events: none;
opacity: 0;
transition: opacity 0.4s ease;
}
什么时候不该用磁吸和光标跟随
- 表单类页面:用户需要精确点击,磁吸会干扰定位。
- 信息密集型后台:动效会分散注意力,降低效率。
- 低端移动设备:磁吸依赖高频
mousemove,而移动端根本没有指针。 - 已经有很多动效的页面:再叠加磁吸,会让页面显得“躁”。
一条通用的判断标准是:磁吸与光标跟随是“装饰性微交互”,它们不承担任何信息传递功能。因此它们只应该出现在品牌站点、作品集、产品首页这类允许表达个性的场景里。
链接与文本下划线动画
在正文里,链接的下划线是最不起眼、却最常用的微交互。默认的 text-decoration: underline 无法控制动画,因此现代做法是用 background-image 或伪元素来“画”一条线。
方案一:伪元素 + scaleX
最通用的方案。用 ::after 画一条线,通过改变 transform-origin 控制展开方向。
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 2px;
background: currentColor;
transform: scaleX(0);
transform-origin: left center; /* 左 / center / right */
transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1);
}
.link:hover::after { transform: scaleX(1); }
方案二:background-size 动画
用两层 linear-gradient 作为背景,通过改变 background-size 实现“双线同时滑入”。这种方案的优势是不需要 position: relative,可以直接用在行内文本上,并且支持多行换行。
text-decoration: none;
background-image:
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor);
background-size: 0 2px, 0 2px;
background-position: 0 0, 100% 100%;
background-repeat: no-repeat;
transition: background-size 0.35s cubic-bezier(0.2, 0, 0, 1);
}
.link-double:hover {
background-size: 100% 2px, 100% 2px;
}
方案三:文字底部高亮
不做下划线,而是在文字背后刷一道半透明的高亮色块。它比下划线更“宽厚”,适合用在导航与标签上。
text-decoration: none;
background-image: linear-gradient(rgba(37,99,235,0.18), rgba(37,99,235,0.18));
background-repeat: no-repeat;
background-size: 0% 100%;
background-position: 0 100%;
transition: background-size 0.32s cubic-bezier(0.2, 0, 0, 1);
}
.link-highlight:hover { background-size: 100% 100%; }
注意事项
- 保持颜色对比度:悬停后的文字颜色必须仍然满足 4.5:1 的对比度要求。
- 不要移除默认下划线而不给替代:正文中的链接如果没有视觉区分,色盲用户会找不到它。悬停时再出现下划线是不够的,它必须在默认状态下就可辨识。
- 用
currentColor而不是硬编码颜色:这样链接在深色背景中也能自动适配。 - 动画时长控制在 250~350ms:太短看不出来,太长会拖慢阅读节奏。
表单控件的微交互:开关、聚焦与校验
表单是用户停留最久的地方,也是微交互最能发挥作用的地方。一个好的表单控件,应该在用户还没意识到之前,就已经告诉他“接下来会发生什么”。
自定义开关(Switch)
把原生的 <input type="checkbox"> 视觉上隐藏,用相邻兄弟选择器驱动一个视觉化的轨道与滑块。关键是保留输入框本身——不要用 display: none,那样会连键盘焦点一起丢掉。
<label class="switch">
<input type="checkbox" checked>
<span class="switch-track">
<span class="switch-thumb"></span>
</span>
<span>启用消息通知</span>
</label>
/* 视觉上隐藏,但保留可访问性 */
.switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
/* 用弹性缓动让滑块“弹”过去 */
.switch-thumb {
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.switch input:checked + .switch-track .switch-thumb {
transform: translateX(20px);
}
/* 键盘聚焦时给出可见反馈 */
.switch input:focus-visible + .switch-track {
outline: 2px solid #2563eb;
outline-offset: 3px;
}
浮动标签输入框(Floating Label)
输入框聚焦或有内容时,标签从占位符位置缩小上浮到边框上。它既节省空间,又能在用户输入后仍然显示字段含义。
padding: 15px 16px;
border: 1px solid #cbd5e1;
border-radius: 10px;
transition: border-color 0.25s ease, box-shadow 0.25s ease;
}
.field input:focus {
border-color: #2563eb;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.14);
}
/* placeholder 必须是一个空格,才能用 :placeholder-shown */
.field label {
position: absolute;
left: 13px;
top: 50%;
transform: translateY(-50%);
background: #fff;
padding: 0 5px;
color: #94a3b8;
pointer-events: none;
transition: top 0.22s cubic-bezier(0.2,0,0,1), transform 0.22s cubic-bezier(0.2,0,0,1), color 0.22s ease;
}
.field input:focus + label,
.field input:not(:placeholder-shown) + label {
top: 0;
transform: translateY(-50%) scale(0.82);
color: #2563eb;
}
注意 input 上必须写 placeholder=" "(一个空格),否则 :placeholder-shown 不会生效。这是这个方案里唯一一个“魔法”,值得写在注释里。
校验反馈:抖动与勾选
当用户提交了不合法的内容,一个短暂的“左右抖动”比红色文字更能抓住注意力。用 animation 实现,因为它是一次性的强调动画:
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(2px); }
}
.field.error input {
border-color: #ef4444;
animation: shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97);
}
/* 抖动幅度逐次衰减,比等幅抖动更自然 */
/* 动画结束后用 animationend 移除类,才能重复触发 */
field.addEventListener('animationend', () => {
field.classList.remove('error');
});
/* 同时要记得给屏幕阅读器提示 */
field.querySelector('input').setAttribute('aria-invalid', 'true');
微交互的时长标尺:亲手感受 80ms 与 800ms 的差距
“时长该用多少”这个问题,看再多的表格也不如亲手拨一拨。下面这个实验台把同一个“抬升”效果的过渡时长暴露出来,切换按钮,然后把鼠标反复放到目标按钮上,感受不同时长带来的“重量感”差异。
怎么读这个实验
- 80ms:几乎瞬间完成,感觉“没有动画”,适合按下反馈。
- 150ms:能看清但非常干脆,适合颜色、边框这类小范围变化。
- 250ms:舒适区。位移、缩放、阴影都适合这个档位。
- 400ms:开始有“表演感”,适合面板展开这类需要被注意的变化。
- 800ms:明显拖沓,在悬停场景里几乎一定是错的。
还有一个容易被忽略的点:同一个交互的不同属性,可以用不同的时长。比如卡片抬升时,位移用 250ms,阴影用 300ms 并延迟 50ms。这种“错位”会让动画更有层次,但总时长仍然控制在 350ms 以内。
.card {
transition:
transform 0.22s cubic-bezier(0.2, 0, 0, 1),
border-color 0.2s ease,
box-shadow 0.3s cubic-bezier(0.2, 0, 0, 1) 0.04s;
}
.card:hover {
transform: translateY(-4px);
border-color: #93c5fd;
box-shadow: 0 14px 30px -12px rgba(15, 23, 42, 0.28);
}
无障碍:focus-visible 与 prefers-reduced-motion
微交互大量依赖视觉反馈,这恰恰是视障、前庭功能障碍用户最容易受伤的地方。做微交互时,无障碍不是“额外工作”,而是基本功。
永远不要移除焦点指示
outline: none 是前端代码库里最常见的一行“原罪”。键盘用户失去焦点环,就等于在黑暗中摸索。正确的做法是把默认的 :focus 换成 :focus-visible,并设计一个更符合品牌调性的焦点样式。
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px #fff, 0 0 0 6px #2563eb;
}
/* 双层阴影:内层白色模拟“留白”,外层蓝色是焦点环 */
/* 这样在深色与浅色背景上都清晰可见 */
/* 深色背景上换成浅色焦点环 */
.dark-section .btn:focus-visible {
box-shadow: 0 0 0 3px #0f172a, 0 0 0 6px #93c5fd;
}
尊重 prefers-reduced-motion
前庭功能障碍、偏头痛、眩晕症用户会对位移、缩放、旋转产生生理不适。操作系统提供了“减少动态效果”的开关,CSS 通过 prefers-reduced-motion 读取这一偏好。
@media (prefers-reduced-motion: reduce) {
.card:hover {
transform: none;
box-shadow: 0 0 0 2px #93c5fd; /* 改用边框高亮 */
}
.magnet-btn,
.tilt-card {
transform: none !important;
}
.cursor-dot {
display: none;
}
}
/* ✅ 保守方案:只保留颜色变化 */
@media (prefers-reduced-motion: reduce) {
.btn { transition: background-color 0.15s ease; }
.btn:hover { transform: none; }
}
要点是:不是“全部关掉”,而是“减少位移与旋转”。淡入淡出、颜色过渡、透明度变化通常是安全的,保留它们能让界面不至于完全失去反馈。
JS 里也要读取同一偏好
function initMagnet() {
if (mq.matches) return; /* 用户要求减少动态,直接跳过 */
/* ...绑定 mousemove 逻辑 */
}
initMagnet();
/* 用户中途切换系统开关时重新初始化 */
mq.addEventListener('change', (e) => {
if (e.matches) teardownMagnet();
else initMagnet();
});
其他容易忽略的无障碍细节
- 悬停不能是唯一的信息载体:只在
:hover时才显示的关键信息,键盘与触屏用户永远看不到。 - 对比度:悬停后的文字颜色仍需满足
4.5:1。很多“变浅表示禁用”的设计其实是反模式。 - 触摸目标尺寸:移动端可点击区域至少
44×44px,可以用伪元素扩大命中区域而不改变视觉尺寸。 - 闪烁频率:任何闪烁都应低于每秒 3 次,否则可能诱发光敏性癫痫。
.icon-btn {
position: relative;
width: 32px;
height: 32px;
}
.icon-btn::after {
content: '';
position: absolute;
inset: -6px; /* 命中区扩大到 44×44 */
}
性能:微交互最容易踩的合成层陷阱
单个悬停动画的开销微乎其微,但微交互是全站铺开的:一个列表页可能有 200 个卡片、每个卡片都带悬停效果。规模一上来,性能问题就会被无限放大。
只动画 transform 与 opacity
这是唯一一条不能妥协的规则。其他属性要么触发重绘(background-color、box-shadow),要么触发重排(width、top、margin)。
| 属性 | 触发阶段 | 代价 | 微交互中的建议 |
|---|---|---|---|
transform |
Composite | 低 | 位移、缩放、旋转的首选 |
opacity |
Composite | 低 | 淡入淡出、遮罩出现 |
color |
Paint | 中 | 小面积文字安全,长列表注意总量 |
box-shadow |
Paint | 中 | 大模糊半径的阴影代价高,考虑用伪元素模拟 |
background-color |
Paint | 中 | 按钮级别没问题,大面积慎用 |
width / height |
Layout + Paint | 高 | 改用 transform: scale() |
top / left |
Layout + Paint | 高 | 改用 transform: translate() |
margin / padding |
Layout + Paint | 高 | 几乎不该出现在悬停效果里 |
不要滥用 will-change
给每个卡片都加 will-change: transform 是个常见的错误。每一个 will-change 都可能创建一个独立的合成层,而每个图层都要占用显存。一个 1920×1080 的图层大约占 8MB 显存,200 个卡片就是 1.6GB——低端设备直接崩溃。
.card { will-change: transform; }
/* ✅ 让浏览器自己决定(推荐) */
.card {
transition: transform 0.25s cubic-bezier(0.2, 0, 0, 1);
}
/* 只动画 transform,浏览器会在需要时自动提升图层 */
/* ✅ 如果确实要手动控制,只在悬停前添加 */
.card:hover {
will-change: transform;
}
/* ✅ 用 contain 隔离重绘范围 */
.card {
contain: layout paint;
}
will-change: transform,或者写 transform: translateZ(0) 强制提升图层。
transform / opacity,把图层提升交给浏览器;用 contain 限制重绘范围。
长列表中的悬停
在一个 200 项的列表里,每个列表项都有悬停效果时,有两个优化方向:
- 用事件委托代替逐个绑定:在容器上监听
mousemove,通过e.target.closest('.item')判断目标。这样无论列表多长,都只有一个监听器。 - 用
contain: layout paint隔离每一项:让浏览器知道某项的重绘不会影响其他项。 - 对屏幕外的项使用
content-visibility: auto:浏览器会跳过它们的渲染,悬停效果自然也不会执行。
list.addEventListener('mouseover', (e) => {
const item = e.target.closest('.item');
if (!item || item === currentItem) return;
if (currentItem) currentItem.classList.remove('is-hover');
item.classList.add('is-hover');
currentItem = item;
});
不过,对于纯视觉的悬停效果,优先用 CSS 的 :hover 而不是 JS 添加类。CSS 伪类的匹配由浏览器内部优化,比 JS 事件监听更轻量。
CSS 与 JS 的分工:什么时候该交给脚本
微交互的默认选择永远是 CSS:声明式、运行在合成线程、可中断、不需要维护状态。但有几类场景,CSS 确实力不从心。
应该用 CSS 的场景
- 状态驱动的视觉变化:悬停变色、聚焦描边、按下缩小。
- 固定轨迹的动画:下划线展开、光泽扫过、图标旋转。
- 基于伪类的条件样式:
:checked、:disabled、:has()。
必须用 JS 的场景
- 需要知道指针位置:涟漪效果、3D 倾斜、磁吸、光标跟随。
- 需要根据运行时数据计算终点:拖拽结束后根据速度决定滑出距离。
- 需要串行编排多个动画:先淡出、再位移、最后移除 DOM。
- 需要暂停 / 变速 / 反向:CSS 动画做不到这些实时控制。
Web Animations API:JS 的控制力 + CSS 的性能
当你确实需要 JS 控制,但又不希望主线程被逐帧的 style 修改拖垮时,element.animate() 是最佳选择。它用 JS 描述动画,但由浏览器合成线程执行。
function bounce(el) {
return el.animate([
{ transform: 'scale(1)' },
{ transform: 'scale(0.94)', offset: 0.35 },
{ transform: 'scale(1.03)', offset: 0.7 },
{ transform: 'scale(1)' }
], {
duration: 280,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)'
});
}
/* 连续点击时先取消上一个动画,避免叠加 */
let current = null;
btn.addEventListener('click', () => {
if (current) current.cancel();
current = bounce(btn);
});
一个常被忽视的原则:不要在 JS 里逐帧改样式
let pos = 0;
setInterval(() => {
pos += 2;
el.style.left = pos + 'px';
}, 16);
/* ✅ 用 WAAPI,交给浏览器调度 */
el.animate([{ transform: 'translateX(0)' },
{ transform: 'translateX(200px)' }],
{ duration: 300, easing: 'ease-out' });
/* ✅ 或者干脆用 CSS */
el.classList.add('moved');
列表与卡片的入场微交互
虽然入场动画严格来说不算“悬停”,但它和悬停效果经常搭配出现:卡片先错峰淡入,用户再把鼠标移到上面产生抬升。两者衔接得好,整个页面会显得非常完整。
用 CSS 变量计算延迟,而不是手写 N 条规则
错峰动画最容易踩的坑是:为每个元素写一段独立的 nth-child 规则,样式表迅速膨胀到几百行。正确做法是让 JS 给每个元素写入索引变量,CSS 用 calc() 计算延迟。
.item {
--i: 0;
opacity: 0;
transform: translateY(14px);
animation: staggerIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
animation-delay: calc(var(--i) * 70ms);
}
/* ✅ JS:一行搞定 */
items.forEach((el, i) => el.style.setProperty('--i', i));
入场与悬停的衔接
入场动画结束后,元素应该立刻可以响应悬停。这里有一个细节:如果入场用的是 animation,它结束后元素会回到 transform: none(除非设置了 forwards);而悬停用的是 transition。两者作用于同一个 transform 属性时,可能互相干扰。
.card-wrap {
animation: staggerIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
animation-delay: calc(var(--i) * 70ms);
opacity: 0;
}
.card-inner {
transition: transform 0.25s cubic-bezier(0.2, 0, 0, 1);
}
.card-wrap:hover .card-inner {
transform: translateY(-4px);
}
/* 两层各自负责一件事,互不打架 */
错峰的节奏建议:60~80ms 是公认最舒适的区间。间隔小于 30ms 视觉上会糊成一片,大于 120ms 会让用户觉得“怎么还没加载完”。并且总时长要控制在 600ms 以内——超过这个数,就该改用滚动触发,而不是一次性全部播完。
微交互设计检查清单
把全文的核心结论浓缩成一份可以贴在工位上的清单。每写完一个悬停效果,都可以对着它过一遍。
- 只动画 transform 与 opacity:其他属性要么重绘、要么重排,代价高得多。
- 悬停时长 120~250ms,点击反馈 80~150ms:超过 300ms 的悬停会显得拖沓。
- 用 transition 而不是 animation 做悬停:前者可中断,后者被打断会重播。
- 位移幅度控制在 2~6px:超过 10px 容易造成 hover 抖动。
- 用
@media (hover: hover)而不是宽度:触屏设备需要单独一套反馈。 - 只强调一到两个属性:同时变色、位移、放大、旋转,只会让界面变“吵”。
- 给非核心元素加 60~120ms 的进入延迟:过滤掉扫过的指针,减少误触。
- 永远保留焦点指示:用
:focus-visible替代:focus,不要outline: none。 - 尊重 prefers-reduced-motion:减少位移与旋转,而不是粗暴地全部关掉。
- 不要滥用 will-change:只在确实需要时短暂添加,用完移除。
- 长列表用事件委托或 contain 隔离:避免为每个元素绑定监听器。
- mousemove 一定要节流:用 rAF 包装,并且缓存
getBoundingClientRect()。 - JS 生成的临时元素要及时移除:比如涟漪动画结束后必须从 DOM 中删掉。
- 在低端真机上测试:桌面浏览器的性能无法反映真实的移动端体验。
/* 1. 时长与缓动变量 */
:root {
--dur-instant: 90ms; /* 按下 */
--dur-fast: 150ms; /* 颜色 */
--dur-base: 240ms; /* 位移 */
--dur-slow: 360ms; /* 展开 */
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
--ease-accelerate: cubic-bezier(0.4, 0, 1, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* 2. 通用可交互元素 */
.interactive {
transition:
transform var(--dur-base) var(--ease-standard),
box-shadow var(--dur-base) var(--ease-standard),
border-color var(--dur-fast) ease;
}
/* 3. 只在真正支持悬停的设备上启用 */
@media (hover: hover) and (pointer: fine) {
.interactive:hover {
transform: translateY(-3px);
box-shadow: 0 12px 24px -10px rgba(15, 23, 42, 0.3);
}
}
/* 4. 按下反馈对所有设备生效 */
.interactive:active {
transform: scale(0.98);
transition-duration: var(--dur-instant);
}
/* 5. 键盘焦点必须可见 */
.interactive:focus-visible {
outline: none;
box-shadow: 0 0 0 3px #fff, 0 0 0 6px #2563eb;
}
/* 6. 减少动态偏好 */
@media (prefers-reduced-motion: reduce) {
.interactive {
transition: background-color var(--dur-fast) ease;
}
.interactive:hover,
.interactive:active {
transform: none;
}
}
/* 7. 隔离重绘范围 */
.card, .list-item, .widget {
contain: layout paint;
}
微交互的最高境界,是用户根本注意不到它。他们不会说“这个按钮的悬停动画做得真好”,他们只会说“这个网站用起来很舒服”。而这种“舒服”,恰恰是由成百上千个 150 毫秒的细节堆叠出来的。
希望这篇长文能帮你把“加个 hover”从随手一写的习惯,变成一套有依据、有分寸、有边界的工程实践。如果你在项目里遇到过更刁钻的悬停抖动、触屏粘连或性能问题,欢迎回到上面的实验台,亲手改一改参数、比一比手感——那比读十篇文章都管用。