Popover API 实战:用原生 HTML 做出不靠 z-index 的下拉菜单与提示气泡

2026-09-12 0 203

做前端的人多少都被弹层坑过。一个下拉菜单写着写着,突然被父级的 overflow: hidden 剪掉半截;挪到 body 下面吧,定位又要重新算;好不容易算完了,旁边一个 transform: translateZ(0) 又把它关进新的包含块里。最后只能一边加 z-index: 9999,一边在注释里写“别删,删了会被盖住”。

Popover API 就是来终结这个循环的。它已经被 Chrome 114+、Safari 17+、Firefox 125+ 覆盖,也就是说到 2025 年,你不用再考虑兼容性,直接写就行。

先跑通最小的那一个

新建一个 HTML 文件,写这几行:

<button popovertarget="tip">点我</button>

<div id="tip" popover>
  我是一个弹出的面板
</div>

打开就能用。没有 JavaScript,没有 CSS,点击按钮面板就出现,再点一次就收起,按 Esc 也能关掉。

关键在于 popover 这个布尔属性。只要它存在,元素默认就是 display: none,并且被浏览器接管了显示/隐藏的逻辑。

它到底解决了什么

弹层难写,根源在三个地方:

层叠上下文。祖先元素只要沾上 transformfilteropacity 小于 1,就会形成新的层叠上下文,你写在子元素上的 z-index 只能在这个上下文里比较,出不去。

溢出裁剪。父级写了 overflow: hiddenauto,子元素超出部分直接消失,无论你 z-index 写多大。

定位参照。绝对定位是相对最近的定位祖先,想要贴着按钮就得把 DOM 结构改成“按钮和面板同一父级”,可这样又常常和语义结构冲突。

当一个元素通过 showPopover() 打开时,浏览器会把它提升到 top layer。这个词很关键——top layer 是独立于普通文档流的一层,里面的元素不受祖先裁剪,不参与普通 z-index 比较,包含块也换成了视口。上面三个问题,前两个当场消失。

代价是第三个问题变严重了:因为包含块变成了视口,position: absolute 不再相对按钮,而是相对整个视口。所以默认状态下,popover 元素会居中显示在屏幕中间,而不是贴着按钮。这一点后面专门讲。

auto、manual、hint 三种模式

popover 不写值,等价于 popover="auto",这是最常用的。auto 模式下浏览器会自动收场,遇到下面任意一种情况就关闭:

  • 点击页面其他地方
  • 按下 Esc
  • 另一个 auto 类型的 popover 被打开

这个“自动关”是很多人手写弹层时最麻烦的部分,现在浏览器包了。

popover="manual" 就完全不同了,浏览器不再主动关闭它,全由你自己控制。适合提示气泡、常驻浮层这类需要精细管理生命周期的场景。

popover="hint" 是为悬浮提示设计的:它不会关闭已经打开的 auto popover,但当别的 popover 出现时它会自己让位。这个值目前支持面稍窄,稳妥起见,提示气泡用 manual 自己管更保险。

用按钮直接开关

触发不一定非得写 JS。popovertarget 指向目标元素的 id,popovertargetaction 决定动作:

<button popovertarget="confirm" popovertargetaction="show">删除</button>

<div id="confirm" popover>
  <p>确定要删除这条记录吗?</p>
  <button popovertarget="confirm" popovertargetaction="hide">取消</button>
  <button popovertarget="confirm" popovertargetaction="hide">确定</button>
</div>

三个可选值是 showhidetoggle,不写默认是 toggle。做确认框这种“打开靠按钮、关闭也靠按钮”的结构,一行 JS 都不需要。

注意一个细节:popovertarget 只在 <button><input type="button"> 上有效。写在 <a><div> 上不生效,而且不报错,只是静默失效。

加动画:allow-discrete 和 @starting-style

默认的开关是瞬时的。想加淡入淡出,直接写 transition 会发现不起作用,因为元素在关闭时会立刻变成 display: none,过渡根本来不及播放。

需要两样东西配合:

[popover] {
  opacity: 0;
  transform: translateY(-6px) scale(.98);
  transition:
    opacity .16s ease,
    transform .16s ease,
    display .16s allow-discrete,
    overlay .16s allow-discrete;
}

[popover]:popover-open {
  opacity: 1;
  transform: none;
}

@starting-style {
  [popover]:popover-open {
    opacity: 0;
    transform: translateY(-6px) scale(.98);
  }
}

allow-discretetransition-behavior 的一个取值,它的作用是允许 display 这种离散属性参与过渡。正常情况下 display 只能从 none 跳到 block,谈不上“中间态”,但加上它之后,浏览器会等到过渡结束才真正切到 none

overlay 同理,它是控制元素是否留在 top layer 的属性。如果不让它在过渡期间保持,元素会在动画开始的瞬间就从顶层掉出去。

@starting-style 解决的是进场那一帧的问题。元素从 display: none 变成可见时,浏览器找不到“变化前的样式”来做插值,所以需要你用这个规则明确告诉它起点在哪。

定位:从视口居中到贴着按钮

前面说过,popover 在 top layer 里,绝对定位的参照是视口。所以下面这段代码是没用的:

/* 不生效,因为参照物不是按钮 */
#menu {
  position: absolute;
  top: 100%;
  left: 0;
}

传统解法是用 JS 算坐标再写 style.left / style.top,顺带还要监听滚动和 resize。现在有更好的办法:CSS 锚点定位。

#open-btn {
  anchor-name: --menu-anchor;
}

#menu {
  position: absolute;
  position-anchor: --menu-anchor;
  position-area: bottom span-right;
  margin-top: 6px;
}

anchor-name 给按钮起个名字,position-anchor 让面板认这个锚点,position-area 是更省事的写法——它把锚点周围划成九宫格,写 bottom span-right 就是“放在锚点下方,右边缘对齐”。

如果需要更精细的控制,也可以用底层函数:

#menu {
  top: calc(anchor(bottom) + 6px);
  left: anchor(left);
  position-try-fallbacks: flip-block, flip-inline;
}

position-try-fallbacks 值得单独说一句。当面板下方空间不够时,浏览器会自动尝试后面的备选方案。写 flip-block 就是“上下翻一下试试”,flip-inline 是“左右翻一下”。以前这套逻辑要手写几十行测量代码,现在一行搞定。

需要留意的是,锚点定位目前在 Chrome 和 Edge 125+ 上稳定可用,Safari 和 Firefox 还在推进。生产环境用之前建议加 @supports 做一层判断,或者接受面板在旧浏览器上居中显示。

完整案例:一个带筛选的城市选择面板

把前面这些拼起来,做一个实际会用到的东西——点击按钮弹出面板,面板里有搜索框和城市列表,选中后回填到按钮上。

<button id="city-btn" popovertarget="city-panel" aria-expanded="false">
  选择城市
</button>

<div id="city-panel" popover>
  <input type="search" placeholder="输入筛选…" autocomplete="off" aria-label="筛选城市">
  <div id="city-list">
    <button type="button">北京</button>
    <button type="button">上海</button>
    <button type="button">广州</button>
    <button type="button">深圳</button>
    <button type="button">杭州</button>
    <button type="button">成都</button>
  </div>
</div>
#city-btn {
  anchor-name: --city-anchor;
}

#city-panel {
  position: absolute;
  position-anchor: --city-anchor;
  position-area: bottom span-right;
  margin-top: 6px;
  width: 220px;
  padding: 8px;
}

#city-list {
  display: flex;
  flex-direction: column;
  max-height: 220px;
  overflow-y: auto;
}

#city-list button {
  text-align: left;
  padding: 6px 8px;
  border: 0;
  background: none;
  cursor: pointer;
}

接下来是脚本部分,一共四件事:同步无障碍状态、打开时聚焦搜索框、实时过滤、选中后回填并关闭。

const btn = document.getElementById('city-btn');
const panel = document.getElementById('city-panel');
const input = panel.querySelector('input');
const list = document.getElementById('city-list');

// 1. 同步 aria-expanded,popover 不会帮你做这件事
panel.addEventListener('toggle', (e) => {
  btn.setAttribute('aria-expanded', String(e.newState === 'open'));
  if (e.newState === 'open') {
    requestAnimationFrame(() => input.focus());
  }
});

// 2. 输入即过滤
input.addEventListener('input', () => {
  const q = input.value.trim().toLowerCase();
  for (const item of list.children) {
    item.hidden = q !== '' && !item.textContent.toLowerCase().includes(q);
  }
});

// 3. 从搜索框按方向键下,跳到第一个可见选项
input.addEventListener('keydown', (e) => {
  if (e.key !== 'ArrowDown') return;
  const first = [...list.children].find(el => !el.hidden);
  if (first) {
    e.preventDefault();
    first.focus();
  }
});

// 4. 选中
list.addEventListener('click', (e) => {
  const item = e.target.closest('button');
  if (!item) return;

  btn.textContent = item.textContent;
  input.value = '';
  for (const el of list.children) el.hidden = false;

  panel.hidePopover();
  btn.focus();
});

几个地方值得展开讲。

关于 aria-expanded这是 popover 需要你手动补的部分。浏览器知道面板开着还是关着,但屏幕阅读器不知道,得靠属性同步。监听 toggle 事件是最省事的做法,它在状态变化之后触发;如果需要在变化之前做拦截,用 beforetoggle

关于聚焦时机。直接在 toggle 里调用 input.focus() 有时候不生效,因为那一刻面板的布局可能还没完成。包一层 requestAnimationFrame 让它等到下一帧再执行,稳得多。

关于关闭后的焦点。这里我手动调了 btn.focus()。其实 auto 类型的 popover 在关闭时,浏览器会把焦点还回触发元素,所以这一步在大多数情况下是冗余的。但如果你是用 hidePopover() 主动关闭、并且中间焦点已经跑到别处去了,手动补一下更保险。

关于 hidePopover() 的报错。这个方法在面板已经关闭时会抛 InvalidStateError。上面这段代码里它出现在点击回调中,此时面板必然是打开的,所以安全。但如果你的逻辑里有可能重复调用,先判断一下更放心:

if (panel.matches(':popover-open')) {
  panel.hidePopover();
}

提示气泡:用 manual 加一点延迟

提示气泡和下拉菜单的需求不一样。它通常跟着鼠标悬停触发,而且不能一碰就弹,得有个几百毫秒的缓冲,不然鼠标扫过一排按钮时会满屏闪烁。

这种场景用 manual,自己控制开关:

<button id="help-btn">?</button>
<div id="help-tip" popover="manual">
  按 Ctrl + K 可以打开命令面板
</div>
#help-btn {
  anchor-name: --help-anchor;
}

#help-tip {
  position: absolute;
  position-anchor: --help-anchor;
  position-area: top;
  margin-bottom: 8px;
  padding: 6px 10px;
}
const trigger = document.getElementById('help-btn');
const tip = document.getElementById('help-tip');

let showTimer = null;
let hideTimer = null;

function show() {
  clearTimeout(hideTimer);
  if (tip.matches(':popover-open')) return;
  showTimer = setTimeout(() => tip.showPopover(), 400);
}

function hide() {
  clearTimeout(showTimer);
  hideTimer = setTimeout(() => {
    if (tip.matches(':popover-open')) tip.hidePopover();
  }, 120);
}

trigger.addEventListener('mouseenter', show);
trigger.addEventListener('mouseleave', hide);
trigger.addEventListener('focus', show);
trigger.addEventListener('blur', hide);

// 触摸设备上点一下也能看到,避免 hover 语义缺失
trigger.addEventListener('click', () => {
  clearTimeout(showTimer);
  tip.togglePopover();
});

那两个延迟时间是关键。400 毫秒是显示延迟——鼠标只是快速划过时不会触发;120 毫秒是隐藏延迟——从按钮移到气泡上时不至于闪一下就没。这两个值可以按手感微调,但都别低于 80 毫秒,否则快到察觉不到。

另外注意 hide()show() 里的互斥清理。如果用户鼠标进进出出很快,两个定时器可能同时存在,不互相清掉的话会出现“刚关掉又自己弹出来”的诡异现象。

什么时候该用 <dialog> 而不是 popover

这两个东西容易混。简单区分:

popover 不抢焦点,不阻断页面。打开一个 popover 时,页面其他部分依然可以点击、可以滚动、可以被 Tab 到。这是特性不是缺陷——下拉菜单、筛选面板、提示气泡本来就该这样。

<dialog> 的 showModal() 会打断页面。它会自动把焦点移进去并限制在里面,页面其余部分对辅助技术隐藏,而且 Esc 会触发 cancel 事件——这个事件可以 preventDefault(),让你在“表单填了一半”时拦住用户。

所以判断标准很清楚:这个弹层打开时,用户还能不能去操作页面其他部分?能,就用 popover;不能,就用 <dialog>

顺带一提,两个都支持 ::backdrop 伪元素。popover 的 backdrop 默认是全透明的,你得自己给个背景色才会看到遮罩效果;dialog 的 modal 模式也是这样,都需要显式写样式。

兼容性和降级

Popover 本身已经很稳了。如果你还需要支持更早的浏览器,可以检测属性是否存在:

const supportsPopover = HTMLElement.prototype.hasOwnProperty('popover');

if (!supportsPopover) {
  // 退回手写逻辑,比如切换 hidden 属性
}

社区也有现成的 polyfill(OddBird 维护的那个用的人比较多),引入之后基本能按标准写法用。

锚点定位的覆盖面要窄一些,但它是渐进增强的——不支持的时候面板会退回默认的居中显示,功能不丢,只是位置不那么理想。如果你的交互对位置要求很高,可以准备一段 fallback 的坐标计算逻辑。

最后提醒几个容易踩的地方

别在 popover 里塞表单然后按回车。如果面板没有包在 <form> 里,回车可能触发外层表单提交导致页面刷新;如果包在 <form> 里,又要注意默认的 submit 行为。用 type="button" 显式声明按钮类型,能躲掉一大半这类问题。

popover 属性值写错不会报错。写成 popover="atl" 或者带个空格,浏览器会静默当作没写,然后你就得到一个永远显示不出来的元素。这类问题排查起来很浪费时间。

top layer 里的元素查询不到 offsetParent因为它的包含块是视口,offsetParent 返回 null,依赖它的老代码会失效。定位计算请改用 getBoundingClientRect()

嵌套的 auto popover 会互相踢掉。打开内层的时候,外层因为也是 auto,会被自动关闭。如果需要嵌套,至少让其中一层用 manual

把这些理顺之后回头看,弹层这件事确实比几年前干净多了。浏览器把裁剪、层级、自动关闭这些琐碎的部分接了过去,你只需要关心内容和位置。手上的老组件可以挑几个慢慢替换过来。

Popover API 实战:用原生 HTML 做出不靠 z-index 的下拉菜单与提示气泡
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 html Popover API 实战:用原生 HTML 做出不靠 z-index 的下拉菜单与提示气泡 https://www.taomawang.com/web/html/2748.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务