上个月接了个后台管理系统的小活,先按惯例做体感测试。其他都还行,就是这个筛选面板,点开的时候会”闪”一下——不是那种慢慢淡入的打开,是一瞬间跳出来,然后才看到内容往下挪了半像素又回来。
一开始以为是浏览器重绘,打开 DevTools 的 Paint flashing 盯了半天,没毛用。后来去翻代码才知道,这套面板是这么写的:
// 简化后的原逻辑
btn.addEventListener('click', () => {
panel.classList.toggle('is-open')
setTimeout(() => {
panel.setAttribute('aria-hidden', panel.classList.contains('is-open') ? 'false' : 'true')
}, 200)
})
然后 CSS 那边靠 .is-open 来控制 display 和 opacity,还配了个 transition: all .2s。问题就出在 display 是不参与过渡的——从 none 切到 block 是瞬时生效的,所以元素先以最终状态渲染一帧,然后 opacity 的过渡才开始。那一帧就是眼睛看到的”闪”。
以前碰到这种问题的标准解法是:先加 class 触发 display: block,强制 requestAnimationFrame 读一次布局,再改 opacity。或者干脆放弃 display,全程用 visibility 配合。前者丑,后者又会让面板一直占着 DOM 位置、有机会吃到点击。
现在不用绕了。这条路上有三个比较新的东西凑齐了:popover 属性、@starting-style、transition-behavior: allow-discrete。我拿这个筛选项当试验田,改完之后 JS 那一坨基本删干净了,开合也顺了。
下面按我实际改的顺序写,包括中间写错的地方。
第一步:把”打开”这件事交给 popover
popover 是 HTML 上的一个全局属性,加上它就自动拥有了打开/关闭的状态,还顺带进了浏览器的最顶层(top layer),不用再操心 z-index 叠层的破事。触发方式有两种,我用了最省事的 popovertarget:
<button type="button" popovertarget="filter-panel">筛选</button>
<div id="filter-panel" popover>
<label><input type="checkbox"> 只看待发货</label>
<label><input type="checkbox"> 只看已退款</label>
</div>
就这几行,点击按钮面板能开了,点面板外面空白处会自动关,按 Esc 也会关。原来的 JS 里有一大半是在处理”点外面关掉”这个逻辑,现在全删了。
但这时候它是没有动画的,而且默认样式挺难看——它默认是 position: fixed 加上 inset: 0 加 margin: auto 居中显示,还带一圈黑边。所以下一步得先把位置改掉。
我在按钮外面套了个 position: relative 的容器,面板改成绝对定位贴着按钮:
.filter-wrap {
position: relative;
}
#filter-panel {
position: absolute;
inset: auto;
top: calc(100% + 8px);
left: 0;
width: 240px;
margin: 0;
padding: 12px;
border: 1px solid #dcdfe6;
border-radius: 6px;
background: #fff;
box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
}
注意 inset: auto 这句别漏,不然你会看到面板飞到屏幕中间去,还很自信地以为是自己 top/left 写错了。
第二步:让 display 参与过渡
这里是整篇文章最核心的一行。popover 在关闭状态下 UA 样式给的是 display: none,打开时切换到 display: block(准确说取决于具体值)。想让它平滑过渡,就必须显式地告诉浏览器:”display 这个属性的变化,请按离散的方式在过渡结束时再切换”:
#filter-panel {
opacity: 0;
transform: translateY(-6px);
transition:
opacity 180ms ease,
transform 180ms ease,
display 180ms allow-discrete,
overlay 180ms allow-discrete;
}
#filter-panel:popover-open {
opacity: 1;
transform: translateY(0);
}
allow-discrete 是 transition-behavior 的取值,也可以单独写 transition-behavior: allow-discrete。它的含义是:本来离散的属性(display、overlay、content-visibility)也允许参与过渡,规则是在过渡的 0% 处用起始值、100% 处用结束值——打开时从头到尾都保持 block,关闭时反过来保持到最后一帧才变 none。
少了这句,关闭动画直接看不到:元素在第一帧就 display: none 了,opacity 的过渡没机会跑。
overlay 那句也是必须的,但原因不同,下一节单独说。
第三步:@starting-style 解决”从哪来”
上面写完,打开时能淡入了,但方向不对——它是从”当前位置的最终状态”开始的,也就是说 opacity: 0 这个起点根本没生效,元素直接以 opacity: 1 出现然后闪一下。
原因在于:元素从 display: none 变可见的那一帧,浏览器不认为它有”之前的样式”,也就没有起点可供过渡。@starting-style 就是专门用来给这种”首次渲染”提供起始值的:
@starting-style {
#filter-panel:popover-open {
opacity: 0;
transform: translateY(-6px);
}
}
或者用嵌套写法,新版 Chrome 里也认:
#filter-panel:popover-open {
opacity: 1;
transform: translateY(0);
@starting-style {
opacity: 0;
transform: translateY(-6px);
}
}
两种写法效果一样,我用了外面那层,因为 @starting-style 块里还能一起塞别的元素的选择器,集中放好找。
这里有个容易犯的错:起始值必须和最终值”类型一致”,比如 transform 的起始值不能是 none 然后指望过渡到 translateY(0)。none 到具体 transform 函数之间的插值是没法算的,浏览器会直接把整段过渡作废。我第一版就是这么写的,查了十几分钟。写成 translateY(-6px) 就没问题了。
第四步:为什么关闭动画要 overlay
元素打开时在 top layer 里,关闭时会被踢出 top layer。这个”踢出去”的动作默认发生在关闭的瞬间,而不是动画结束之后——所以你会看到面板在动画跑到一半时突然消失。
overlay 这个属性就是控制这件事的。它有两个值 auto 和 none,popover 打开时是 auto(在 top layer),关闭时变 none(不在)。把它一起列入 transition 并且允许离散,就能让它跟 display 一样,在动画最后一帧才切。
所以那句 overlay 180ms allow-discrete 不是可有可无的装饰。漏了它,效果是:打开正常,关闭时面板”啪”地消失,动画像是被吃了。
第五步:让 :has() 接管状态展示
面板里有几个复选框,原来的 JS 要监听 change 事件,在按钮上加个”已选 2 项”的角标,再给按钮加个高亮 class。这部分改完发现,纯 CSS 也能做到,因为 :has() 能从子元素反向影响父级。
<div class="filter-wrap">
<button type="button" popovertarget="filter-panel">筛选</button>
<div id="filter-panel" popover>
<input type="checkbox" id="f1"><label for="f1">只看待发货</label>
<input type="checkbox" id="f2"><label for="f2">只看已退款</label>
</div>
</div>
/* 只要有任意一个勾选,外层按钮就高亮 */
.filter-wrap:has(input:checked) > button {
border-color: #409eff;
color: #409eff;
}
/* 面板打开时,按钮保持按下态 */
.filter-wrap:has(:popover-open) > button {
background: #ecf5ff;
}
:popover-open 这个伪类专门匹配处于打开状态的 popover,和 :has() 一配合,就能在父级上做文章了。这两条规则把原来监听 change 事件和监听面板开合的那两段 JS 全替掉了。
踩到的几个坑
坑一:transition: all 会坑你。 我一开始图省事写了 transition: all 180ms allow-discrete,结果面板里输入框的边框、文字颜色全在这个过渡里跟着动,勾选的时候整个面板有种黏糊糊的感觉。显式列出要过渡的属性,永远是对的。
坑二:@starting-style 只对”首次渲染”生效。 我一开始想用它做面板内容切换时的过渡,失败——它不是在每次状态变化时都触发,只在元素第一次显示时。想做出列表重新排列的动画,那是另一个话题。
坑三:优先级问题。 UA 样式里 [popover]:popover-open 是有一定权重的,如果你的 #filter-panel 选择器权重不够(比如你用的是类名 .panel),某些属性会被 UA 样式盖住。我遇到的是 margin 被覆盖导致居中偏移,加了个 ID 就好了。不过这属于偶发,遇到时打开 DevTools 的 Computed 面板看来源最直接。
坑四:Safari 的版本问题。 项目里要照顾一批 macOS 上没更新的用户。我查了下 caniuse,allow-discrete 在 Safari 17.4 之后才好,@starting-style 是 17.5 起。所以在 Safari 17.4 这套环境下,会出现”能打开但没动画”的降级效果,不会白屏也不影响功能,我认了。但如果你们业务要求老版本也有动画,得留个 @supports 兜底。
@supports not (transition-behavior: allow-discrete) {
/* 老浏览器:不做动画,保证功能可用 */
#filter-panel {
transition: none;
}
}
这里有个小细节:@supports not 里我只写了关闭过渡,没有改 display 逻辑。因为在不支持 allow-discrete 的环境下,display 本来就不参与过渡,写了也没用,反倒可能让面板在某些时刻保持不可见。保守一点,直接不玩动画最稳。
坑五::has() 和 :popover-open 组合时的小意外。 我写了 .filter-wrap:has(:popover-open),本意是匹配面板打开,结果页面里另一个不相干的 popover 打开时,筛选按钮也高亮了。因为 :has() 的匹配范围是整个后代树,只要有任意一个后代满足就成立。改成 .filter-wrap:has(#filter-panel:popover-open) 就精确了。
改完之后的样子
整块筛选面板的 JS 从原来的一百多行(开合控制、外面点击关闭、checkbox 变化、角标更新)降到了 0 行。功能上还多了几样原来没有的:Esc 关闭、点空白关闭、打开时自动焦点管理、无障碍属性全部由浏览器负责,屏幕阅读器读出来的东西比以前对。
HTML 里那些 aria-expanded、aria-hidden 我也顺手删了。popover 的语义本身已经说明了它是弹出层,再手动加反而容易打架——比如原来那个 setTimeout 里更新 aria-hidden 的写法,在关闭动画期间它的值是错的,辅助技术会读到矛盾的状态。
顺手把这个模式推广到了系统里另外三个地方:表格的列设置、头像的下拉菜单、以及批量操作的确认浮层。基本就是复制粘贴改个 ID,每个都省了七八十行 JS。
几个使用上的建议
这套东西现在没必要再用 JS 去 classList.toggle 了。如果确实需要在代码里开关,用 element.showPopover() / hidePopover() / togglePopover(),别去操作 class,两条状态线混着用迟早出问题。
定位方面,如果面板和按钮的对应关系比较复杂(比如按钮靠近视口右边、面板需要自动往左翻),可以看看 anchor positioning。不过那套的语法在过去一年里改过几版命名,各浏览器支持进度也不一致,我这次故意没碰,用简单的相对定位就够用了。等 position-anchor 和 position-area 稳定了再上不迟。
最后说一句,这类”新特性替代 JS”的改造,收益最大的往往不是代码行数,而是那些你原来根本没想到要处理的情况——比如现在按住 Esc 能关、点外面能关、焦点跑出去了会自动收回来。这些细节靠手写 JS 补齐的成本,比多写几行 CSS 高太多了。

