先记住这个答案
opacity 可以平滑过渡但不会让元素真正消失,visibility 能让元素消失但只会瞬间切换。把两者结合起来,给 visibility 的过渡加一个延迟,就能先让 opacity 从 1 渐变到 0,等动画播完再把 visibility 切成 hidden。淡入时反过来:visibility 立刻变成 visible,然后 opacity 从 0 过渡到 1。
opacity: 0的元素仍然可以被点击visibility是离散切换,没有中间态- 淡出时给
visibility加延迟,淡入时取消延迟 visibility不触发布局重排
经典写法和时序
这是做 tooltip、下拉菜单、弹窗等组件最常用的纯 CSS 淡入淡出方案。关键在于淡出和淡入的 transition 声明不一样。
.tooltip {
/* 隐藏状态 */
opacity: 0;
visibility: hidden;
/* 淡出:opacity 花 0.2s 渐隐,visibility 延迟 0.2s 再切换 */
transition: opacity 0.2s ease, visibility 0s linear 0.2s;
}
.trigger:hover .tooltip {
/* 显示状态 */
opacity: 1;
visibility: visible;
/* 淡入:visibility 立刻生效(延迟 0s),opacity 花 0.2s 渐显 */
transition: opacity 0.2s ease, visibility 0s linear 0s;
}淡出时的时序:鼠标移开,opacity 开始从 1 过渡到 0(耗时 0.2 秒)。visibility 的 transition-delay 是 0.2 秒,所以它等 opacity 播完才从 visible 切成 hidden。在这 0.2 秒的过渡期间,元素仍然是 visible 的,opacity 动画才能正常显示。
淡入时的时序:鼠标移入,visibility 延迟为 0 秒,立刻变成 visible,然后 opacity 从 0 过渡到 1。
为什么不用 display: none
display: none 会把元素从渲染树中移除。元素不在渲染树里,opacity 的过渡根本不会执行,因为浏览器没有「之前的值」可以作为起始帧。
另外,display 的切换会触发回流(reflow),浏览器要重新计算所有受影响元素的位置和尺寸。visibility: hidden 只是让元素不可见、不可交互,它仍然占据布局空间,不会触发回流。对于频繁显隐的组件(比如快速划过菜单项时的子菜单),visibility 的性能开销小得多。
如果确实不想让隐藏的元素占空间,可以在 visibility: hidden 的基础上加 position: absolute 脱离文档流。
visibility 的特殊过渡行为
visibility 不是数值属性,不能像 opacity 那样被插值。但 CSS 规范对它有特殊处理:在过渡期间,只要起始值或目标值中有一个是 visible,整个过渡期间元素都保持 visible,直到过渡结束的那一帧才切换。
这就是为什么 transition-delay 在 visibility 上这么好用——它不是真的在「过渡」visibility,而是在延迟那个瞬间切换的时刻。理解了这一点,就不会困惑于「visibility 明明不能插值为什么还能写 transition」。
容易答错的地方
- 淡入淡出用同一条
transition声明 - 淡入和淡出时
visibility的延迟是不一样的。淡出时延迟等于opacity的过渡时长,淡入时延迟为 0。如果只写一条,要么淡出时元素提前消失,要么淡入时元素显示会延迟。 - 以为
opacity: 0的元素不能被点击 opacity只影响视觉渲染,不影响交互。opacity: 0的按钮仍然可以点击、仍然可以获得键盘焦点。不加visibility: hidden,用户可能误触到看不见的元素。
面试官还会怎么问?
pointer-events: none 能替代 visibility: hidden 吗?
pointer-events: none 只阻止鼠标事件,键盘仍然能聚焦到元素上,屏幕阅读器也会读到它。visibility: hidden 则同时阻止鼠标、键盘和辅助技术的交互,更彻底。
这套方案和 JS 控制 display 比,哪个好?
纯 CSS 方案不需要 JavaScript,性能更好,代码更简单。但如果隐藏后不想让元素占据 DOM(比如长列表中的虚拟滚动),还是需要 JS 控制挂载和卸载。两者适用场景不同。
淡出结束后怎么触发回调?
CSS 过渡结束时会触发 transitionend 事件。在 JavaScript 里监听这个事件,判断 event.propertyName === 'opacity',就可以在淡出完成后执行清理逻辑,比如移除 DOM 节点。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。