移动端无:hover状态,因触控设备无悬停动作;真机上:hover残留是因touchstart被模拟为hover起点但无退出事件;需用@media (hover: hover) and (pointer: fine)检测并配合:active或js模拟实现跨端一致交互。

移动端压根没有 :hover 状态——不是你写错了,也不是浏览器 bug,而是触控设备物理上不存在“悬停”这个动作。强行在移动端依赖 :hover 做核心反馈,必然卡住、残留或完全不触发。
为什么真机上 :hover 样式被划掉或点完不消失
iOS Safari 和 Android Chrome 会把第一次 touchstart 当作一次模拟 :hover 的起点,但没有对应“手指移开”的事件来退出它。结果就是:点一下按钮,背景色/缩放卡住几秒,直到滚动、切页或点空白处才可能清除——这行为是规范定义的,不是异常。
- 元素没被识别为可交互目标(比如纯
<div> 没加 <code>cursor: pointer或tabindex="0") - 父容器设了
overflow: hidden,导致:hover触发的transform或box-shadow被裁剪,hit-test 失败 - 用了
pointer-events: none,或被遮挡层挡住 - 在非语义可点击元素(如
<div>)上直接写 <code>:hover,iOS Safari 默认禁用@media (hover: hover) and (pointer: fine)怎么写才真正生效这是唯一符合 W3C 规范的检测方式,它不判断“是不是手机”,而是判断“当前主输入设备是否支持可靠悬停 + 指针精度高”。单独用
@media (hover: hover)在 iPad 或 Surface 上恒为 true,完全不可靠。- 必须带值:
@media (hover: hover)才有效;@media (hover)整条规则会被忽略 - 必须搭配
(pointer: fine),排除触控笔等粗粒度输入误判 - 必须放在常规样式后面,否则会被基础
.btn:hover覆盖 -
transition属性也得包进这个媒体查询里,否则移动 Safari 可能解析到 transition 却不触发 hover,造成 Vue/React 组件重渲染时意外动画 - Tailwind 不内置该查询,得手写 CSS 块,例如:
@media (hover: hover) and (pointer: fine) { .card:hover { @apply -translate-y-1 transition-transform duration-300; } }
:active是移动端最稳的瞬时反馈替代方案:active在手指按下瞬间触发、松开即退,行为接近 PC 端的:hover + :active组合。但它在 iOS Safari 默认不生效,除非满足以下任一条件:- 元素或其祖先有
cursor: pointer(注意:纯 CSS 声明在移动端被忽略,无效) - 元素或其祖先绑定了
touchstart事件(最简做法:) - 元素本身是
<button></button>或带role="button"且有tabindex
实操建议:
:active动画持续时间建议 ≤ 0.15s;别只改背景色,组合transform: scale(0.98)+opacity: 0.9更易感知;确保父容器没设overflow: hidden,否则缩放会被裁剪。需要维持悬停态?JS 模拟
is-hovered类要避开三类坑如果交互依赖“悬停态维持”(比如下拉菜单、图片放大),不能只靠伪类。JS 模拟是最可控路径,但容易踩坑:
- 触摸穿透:快速连点多个元素,前一个可能残留;建议统一用事件委托,在父容器监听,靠
event.target.matches('[data-hoverable]')判断目标 - 滚动干扰:页面滚动时触发
touchstart,误判为点击;需加{ passive: false }并调用e.preventDefault() - 状态清理不及时:别在
touchend立即移除 class——用户滑动时touchend不一定触发;推荐用setTimeout(() => el.classList.remove('is-hovered'), 300)或监听touchcancel
真正难的从来不是怎么写
:hover,而是怎么让它的边界在不同输入方式之间自然消失——它藏在pointer: fine的判定里,藏在touchcancel的监听里,也藏在你忘了给父容器加will-change: transform的那一行注释里。 - 必须带值:











