移动端无:hover“移出”机制,首次touchstart激活后不自动清除,需依赖滚动等外部事件重置;:active需绑定touchstart事件或使用button元素才生效;媒体查询须用(hover:hover)and(pointer:fine)精准匹配真悬停设备。

不是失效,是悬停状态根本没有退出机制——移动端压根不支持:hover的“移出”语义。
为什么点一下就卡住不退
iOS Safari 和 Android Chrome 会把第一次 touchstart 当作 :hover 的起点,但不会在 touchend 或手指抬起时自动清除它。浏览器依赖外部事件(比如滚动、切页、焦点切换)来重置这个状态,而这些动作并不总发生,也不及时。你看到的“残留”,其实是悬停上下文一直挂着没被释放。
-
<button></button>比<div> 更容易卡住,因为原生控件自带高亮逻辑,和 CSS <code>:hover并行运行 - 微信/QQ 内置浏览器(X5 内核)甚至完全忽略
:hover状态更新,残留更顽固 - 滚动时触发
touchstart,也可能误激活:hover,造成样式异常激活 - 必须写成
@media (hover: hover) and (pointer: fine),才能精准筛出“真鼠标/触控板”场景 -
@media (hover: hover) and (pointer: coarse)是无效组合——W3C 规范里二者互斥,这条永远不匹配 - 这条规则要放在常规样式之后,否则会被未包裹的
.btn:hover直接覆盖 -
transition属性也得一起包进去,否则 Safari 可能解析了动画但不触发hover - 元素或其任意祖先绑定了
touchstart事件(最简做法:) - 元素是
<button></button>或带role="button"且有tabindex="0" - 设置了
cursor: pointer(注意:纯 CSS 声明在移动端被忽略,无效) - 滚动干扰:页面滚动时触发
touchstart,误判为点击,样式异常激活 - 状态残留:快速连点多个元素,前一个的
is-hovered类可能没清除就重复添加 - 内存泄漏:单页应用中组件反复挂载/卸载,没做事件清理就会累积监听器
@media (hover: hover) 单独用反而让问题更糟
这个媒体查询只判断设备“是否支持悬停能力”,不关心当前输入方式。iPad 接妙控板、Surface 插触控笔、甚至部分安卓平板,都会同时上报 hover: hover 和 pointer: coarse,导致你写的 .menu:hover { display: block; } 在手指点一下后就“卡开不收”。
:active 不是默认可用的替代方案
:active 是移动端最稳的瞬时反馈替代,但它在 iOS Safari 默认被禁用,除非满足以下任一条件:
另外,:active 动画建议 ≤ 0.15s;只改背景色太单薄,组合 transform: scale(0.98) + opacity: 0.9 更自然;父容器若设了 overflow: hidden,可能裁掉缩放区域。
JS 模拟 is-hovered 类时最容易漏掉的三件事
监听 touchstart/touchend 手动切 class 看似灵活,但实操中常踩坑:
更稳妥的做法是用事件委托,在父容器监听 touchstart,靠 event.target.matches('[data-hoverable]') 判断目标;移除时用 setTimeout(() => el.classList.remove('is-hovered'), 300),避开 iOS 的 300ms click 延迟窗口;同时监听 touchcancel 防止滑动中断后状态卡死。
真正难的从来不是怎么写 :hover,而是怎么让它在不同输入方式之间自然消失——它藏在 pointer: fine 的判定里,藏在 touchcancel 的监听里,也藏在你忘了给父容器加 will-change: transform 的那一行注释里。











