微交互与悬停效果

张玥 2026年9月17日 阅读时间 45分钟
微交互 悬停效果 :hover 反馈设计 无障碍
CSS高级技巧与最佳实践之微交互与悬停效果

微交互是界面里最不起眼、却最决定“手感”的那一部分。用户不会记得你的导航栏有几级菜单,但一定会记得按钮按下去有没有回应、链接悬停时下划线是从哪边滑出来的、输入框获得焦点时标签有没有轻轻上浮。这些加起来不到 200 毫秒的细节,构成了一个产品“精致”与“粗糙”的分水岭。微交互的难点从来不是“做不出来”,而是“做得刚刚好”。本篇 45 分钟深度长文,从状态伪类讲到 hover 的三重陷阱,从按钮的十种范式讲到 3D 倾斜、磁吸、光标跟随、涟漪反馈,再落到时长标尺、无障碍与性能代价,配合多个可以亲手拨动的交互实验台,帮你把“加个 hover”这件事,做成一套有章法、可复用、不伤性能的工程实践。

微交互的本质:一次反馈只值 100 毫秒

微交互(Microinteraction)这个概念由 Dan Saffer 在《微交互:细节设计成就卓越产品》中系统提出。他把任何一个微小的交互拆成四个部分:

1
Trigger
触发器
用户做了什么,或系统发生了什么。悬停、点击、聚焦、输入、拖拽,或者数据加载完成、校验失败、网络断开。
2
Rules
规则
触发器之后系统应该如何响应。点击按钮是立即提交,还是先进入 loading;输入框失焦是先校验,还是直接放行。
3
Feedback
反馈
用户能感知到的部分:颜色变化、位移、缩放、音效、震动、文字提示。本文几乎全部篇幅都在讨论这一层。
4
Loops
循环与模式
这个交互是一次性的,还是会重复?它有没有“开启 / 关闭”两种模式?重复播放的动画,性能代价会被放大无数倍。

需要强调的是:反馈永远是为规则服务的。如果规则本身不合理——比如点击后没有任何提示地干等 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 */
transition 可中断、可回退,微交互的首选
animation 适合一次性的强调动画,如抖动、脉冲
时长 悬停 120~250ms,点击 80~150ms
禁忌 在 hover 里做不可逆的位移或尺寸变化

状态伪类全解::hover 只是其中一员

很多人写微交互时只用 :hover,这是远远不够的。一个完整的交互状态机至少包含下面这些伪类,它们共同决定了一个控件在“被指向、被聚焦、被按下、被禁用”时分别长什么样。

伪类 触发条件 典型用途
:hover 指针悬停在元素上 抬升、变色、显示次级信息
:focus 元素获得焦点(点击或 Tab) 兼容老浏览器,但会误伤鼠标点击
:focus-visible 浏览器判断“需要显示焦点”时 键盘导航时才显示焦点环,首选
:focus-within 自身或后代获得焦点 整张表单卡片的高亮
:active 元素被按下(鼠标或触摸) 按下缩小的即时反馈
:checked 复选框 / 单选框被选中 自定义开关与选择器
:disabled 元素被禁用 降低对比度、取消指针样式
:has() 内部包含匹配某选择器的元素 父元素根据子元素状态做出反应
:target URL 片段与该元素 id 匹配 锚点跳转后的高亮提示

LVHA 顺序:一个不能乱的铁律

链接的四个状态伪类必须按固定顺序书写,否则后写的会覆盖先写的。助记口诀是 LoVe HAte:

/* ✅ 正确顺序:link → visited → hover → active */
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 键盘用户的生命线,绝不能删
:active 按下瞬间的“确认感”,通常 80ms 就够
:focus 现代项目里应尽量被 :focus-visible 取代

顺序问题:当 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;
  }
}
hover: hover 设备支持真正的悬停(鼠标、触控板)
hover: none 主要输入方式不支持悬停(触摸屏)
pointer: fine 精确指针,如鼠标
pointer: coarse 粗略指针,如手指

注意:@media (hover: hover) 询问的是设备的“主要输入机制”,而不是“此刻是否有鼠标”。一台带触摸屏的笔记本,可能同时满足 hover: hover 与 pointer: fine,这正是我们想要的——它确实支持鼠标悬停。

陷阱二:抖动(Hover Flicker)

抖动是悬停效果里最经典、最令人抓狂的 bug。它的成因很简单:悬停导致元素移出了指针所在的位置,于是 :hover 失效,元素弹回去,指针又重新落在元素上,:hover 再次生效……如此循环,元素开始疯狂闪烁。

/* ❌ 抖动:向上移动 20px,指针脱离元素 */
.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;或者外层容器固定不动,只让内部元素产生位移与缩放。

陷阱三:误触与注意力分散

鼠标在页面上移动时,会不经意扫过大量元素。如果每一个元素都在悬停时播放一段花哨的动画,整页就会像圣诞树一样闪个不停,用户的注意力被反复打断。

  • 克制幅度:2~6px 的位移、1.02 倍的缩放,已经足够被感知。
  • 加一点延迟:给非核心元素的悬停效果加 60~120ms 的 transition-delay,只有真正停留的指针才会触发。
  • 只强调一个属性:不要同时变色、位移、放大、加阴影、旋转。选一到两个就够了。
  • 关键操作不加延迟:按钮、链接这类用户明确指向的元素,必须即时响应。
/* 用 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 的选择:从左边展开、从中间展开、从底部升起,给人的感觉完全不同。

.btn-fill {
  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,阴影的模糊半径同步放大。

.btn-lift {
  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() 来实现,性能更好。

.btn-shine::after {
  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)

用两个伪元素分别绘制底部横线与右侧竖线,悬停时沿对角线方向依次展开。这种效果比单纯变色更有“绘制感”,适合用在次要按钮上。

.btn-outline::before {
  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,同时透明度归零,动画结束后移除该元素。

const btn = document.querySelector('.ripple-btn');
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,就足以传达“可点击”的信号。它几乎零成本,且永远不会出错。

.btn-basic {
  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 倾斜的原理

核心只有三步:

  1. 父元素设置 perspective,给子元素一个“景深”。
  2. 监听 mousemove,计算指针在卡片内的相对坐标 (px, py),取值范围 0~1。
  3. 把 px 映射为 rotateY,把 py 映射为 rotateX,角度通常控制在 ±10deg 以内。
const card = document.querySelector('.tilt-card');

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 里:

glare.style.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 ticking = false;
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,而且不做节流。快速移动鼠标时,主线程会被打满。
应该这样
缓存矩形、用 rAF 节流、倾斜角度控制在 ±10deg 以内,并给卡片加 will-change: transform。

磁吸与光标跟随:让元素“看向”用户

磁吸(Magnetic)是近年来高端站点里非常流行的一种微交互:当指针靠近按钮时,按钮会向指针方向轻微偏移,仿佛被吸引;指针离开后,它又平滑归位。它传达的是一种“这个界面在回应我”的错觉。

磁吸的实现思路

核心是计算指针到按钮中心的向量,然后按这个向量的比例给按钮一个位移。距离越近,偏移越大;超过某个半径,偏移归零。

const stage = document.querySelector('.magnet-stage');
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 stage = document.querySelector('.cursor-stage');
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';
});
/* 对应的 CSS:用负 margin 让圆心对准指针 */
.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 控制展开方向。

.link::after {
  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,可以直接用在行内文本上,并且支持多行换行。

.link-double {
  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;
}

方案三:文字底部高亮

不做下划线,而是在文字背后刷一道半透明的高亮色块。它比下划线更“宽厚”,适合用在导航与标签上。

.link-highlight {
  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,那样会连键盘焦点一起丢掉。

/* HTML 结构 */
<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)

输入框聚焦或有内容时,标签从占位符位置缩小上浮到边框上。它既节省空间,又能在用户输入后仍然显示字段含义。

.field input {
  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 实现,因为它是一次性的强调动画:

@keyframes shake {
  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 移除类,才能重复触发 */
const field = document.querySelector('.field');
field.addEventListener('animationend', () => {
  field.classList.remove('error');
});

/* 同时要记得给屏幕阅读器提示 */
field.querySelector('input').setAttribute('aria-invalid', 'true');

微交互的时长标尺:亲手感受 80ms 与 800ms 的差距

“时长该用多少”这个问题,看再多的表格也不如亲手拨一拨。下面这个实验台把同一个“抬升”效果的过渡时长暴露出来,切换按钮,然后把鼠标反复放到目标按钮上,感受不同时长带来的“重量感”差异。

transition-duration: 250ms;

怎么读这个实验

  • 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,并设计一个更符合品牌调性的焦点样式。

/* ✅ 用 box-shadow 做焦点环,可以跟随圆角 */
.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 里也要读取同一偏好

const mq = window.matchMedia('(prefers-reduced-motion: reduce)');

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;
}
不要这样
在 CSS 里给所有卡片写 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 描述动画,但由浏览器合成线程执行。

/* 用一个短暂的“弹跳”反馈代替 CSS 类切换 */
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 :hover 纯视觉状态变化,首选
WAAPI 需要控制或编排时使用
rAF + style 需要跟随指针时使用,务必节流
setInterval 永远不要用来做动画

列表与卡片的入场微交互

虽然入场动画严格来说不算“悬停”,但它和悬停效果经常搭配出现:卡片先错峰淡入,用户再把鼠标移到上面产生抬升。两者衔接得好,整个页面会显得非常完整。

点击按钮,重播错峰入场
首页 教程 CSS 微交互 悬停 实践

用 CSS 变量计算延迟,而不是手写 N 条规则

错峰动画最容易踩的坑是:为每个元素写一段独立的 nth-child 规则,样式表迅速膨胀到几百行。正确做法是让 JS 给每个元素写入索引变量,CSS 用 calc() 计算延迟。

/* ✅ CSS */
.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”从随手一写的习惯,变成一套有依据、有分寸、有边界的工程实践。如果你在项目里遇到过更刁钻的悬停抖动、触屏粘连或性能问题,欢迎回到上面的实验台,亲手改一改参数、比一比手感——那比读十篇文章都管用。