你以为给 popover 加个滑入动画只能靠 JS?今天一个 CSS 伪类把它彻底原生化了

前端的人大概都干过这件事:页面上要加一个下拉提示或者浮层,先想「用哪个库」,然后 npm install,然后写 toggle 逻辑,然后写 outside-click 监听,然后写 ESC 关闭……结果光一个 tooltip 就写了 60 行。

今天这件事,浏览器自己会了。

靠一个 HTML 属性,弹窗自己会开也会关

<div popover>我是浮层</div>,再加一个 <button popovertarget="my-popover">点我</button>——点击按钮,浮层自动弹出,点击外部或者按 ESC,浮层自动关闭。

这就是 Popover API,Chrome 114 / Safari 17.2 / Firefox 125 全部稳定,Baseline 2024,~96% 全球覆盖率。

但光弹出还不够。真正让人头疼的是动画:弹出时要有一个从下往上滑入的过渡,关闭时要淡出。没用 Popover API 之前,这是纯 JS 的活儿。

:popover-openCSS 终于认识「打开状态」了

在 Popover 之前,你给 <dialog> 加动画,因为 dialog 有 ::backdrop,有 [open] 属性可以用 CSS 选择器选中打开状态。但普通 div 做的浮层,CSS 完全不知道它当前是开还是关。

现在 :popover-open 伪类解决了这个问题——它只在 popover 真正打开时匹配。

[popover] {
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 0.25s ease, transform 0.25s ease,
    display 0.25s allow-discrete, overlay 0.25s allow-discrete;
}

[popover]:popover-open {
  opacity: 1;
  transform: translateY(0);
}

三行核心代码,入场滑入 + 淡入,关闭时自动反向播放。这是 MDN 官方示例里的标准写法。

为什么多了 displayoverlay

这是最让人困惑的地方。transition 里为什么写 display 0.25s allow-discreteoverlay 0.25s allow-discrete

display allow-discrete 是入场退场动画的关键。

Popover 打开时,浏览器把 display: none 变成 display: block;关闭时从 block 变回 none。这个切换是离散的(瞬间完成),正常情况下 transition 不生效。加上 allow-discrete 关键字后,浏览器允许 display 参与过渡——进场的 0% 处变成 block,退场的 100% 处才变成 none。也就是说,关闭动画有完整的时间窗口可以播完,用户才能看到「消失」的过程。

overlay allow-discrete 是 top layer 的延迟退出机制。

Popover 打开时进入 CSS Top Layer(脱离文档流,处于最顶层)。如果关闭时立即移除出 Top Layer,动画还没播完浮层就已经不在渲染层了,视觉上就是「闪一下就没了」。加上 overlay allow-discrete,Top Layer 的移除被延迟到动画结束之后,保证整个退场过程流畅可见。

@starting-style:入场动画的起点声明

入场动画需要一个起点状态。:popover-open 只匹配打开后的状态,CSS 不知道「打开前长什么样」,所以需要一个 @starting-style 声明入场起点:

@starting-style {
  [popover]:popover-open {
    opacity: 0;
    transform: translateY(8px);
  }
}

这告诉浏览器:「popover 第一次出现时,从 opacity 0 和 translateY(8px) 开始过渡到打开状态。」每一次打开,都会触发这个起点→终点的过渡。

::backdrop:弹出层后面的遮罩也能做动画

Popover 打开时后面会生成一个 ::backdrop 伪元素,可以给它加背景遮罩和动画:

[popover]::backdrop {
  background-color: transparent;
  transition: background-color 0.3s ease;
}

[popover]:popover-open::backdrop {
  background-color: rgb(0 0 0 / 25%);
}

@starting-style {
  [popover]:popover-open::backdrop {
    background-color: transparent;
  }
}

点击按钮,背景从透明渐变到带透明度黑色遮罩,同时 popover 滑入,一气呵成。

三个坑,踩过才知道

坑一::popover-open 优先级和普通 [popover] 相同,顺序很重要。

/* ❌ 错误:同优先级,后者覆盖前者,入场样式失效 */
[popover]:popover-open { opacity: 1; }
[popover] { opacity: 0; }

/* ✅ 正确:关闭状态的声明在前面 */
[popover] { opacity: 0; }
[popover]:popover-open { opacity: 1; }

坑二:Popover 用隐式锚点定位时,关闭动画可能抖动。

如果 popover 通过 popovertarget 隐式关联到触发按钮,关闭时 anchor 关系会被提前移除,导致位置计算出问题。解法是把 position-anchor 显式设为不存在值,或者关闭动画不要用 translate

/* 关闭动画不用位置变化,只用透明度 */
[popover] {
  opacity: 0;
  transform: none; /* 不用 translateY */
  transition: opacity 0.25s ease, display 0.25s allow-discrete, overlay 0.25s allow-discrete;
}

坑三:::backdrop 退场动画永远无法触发。

Popover 关闭时,backdrop 立即从 DOM 移除,没有任何元素可以参与动画——所以背景遮罩只能做入场,不能做退场。如果需要退场遮罩动画,用 <dialog>showModal() 代替,backdrop 在 dialog 关闭后依然可以被 CSS 控制。

三步落地

第一步:搜你的项目里所有手动写了 toggle 逻辑的浮层组件。

只要用了 show/hide class 加 display: none/block 的地方,就是 Popover API 的候选替换点。先从 tooltip 和下拉提示开始。

第二步:把 HTML 属性加上去,看能不能零改动跑起来。

<!-- 触发按钮 -->
<button popovertarget="my-tip">查看详情</button>

<!-- 浮层内容 -->
<div popover id="my-tip">这里是提示内容</div>

浏览器自动处理开/关逻辑,行为和之前一样,但少了 30 行 JS。

第三步:把动画加上,渐进增强。

@supports (transition-behavior: allow-discrete) {
  [popover] {
    opacity: 0;
    transform: translateY(4px);
    transition: opacity 0.2s ease, transform 0.2s ease,
      display 0.2s allow-discrete, overlay 0.2s allow-discrete;
  }
  [popover]:popover-open {
    opacity: 1;
    transform: translateY(0);
  }
  @starting-style {
    [popover]:popover-open {
      opacity: 0;
      transform: translateY(4px);
    }
  }
}

@supports 包裹保证旧浏览器降级不报错,整个方案在 Chrome 114+ / Safari 17.2+ / Firefox 125+ 全部可用,96%+ 全球覆盖率的 Baseline 特性,生产环境直接上。

写弹窗组件是前端绕不开的活儿,但写 toggle 逻辑不应该是。用好 :popover-open + @starting-style + overlay allow-discrete 这三件套,60 行 JS 变成 3 行 CSS,这件事在 2026 年终于可以说「浏览器自己会了」。

评论区

0 条评论

登录后可评论。