做过前端的人大概都写过这样的弹层:点一个按钮,弹出一块面板,点外面把它关掉,按 Esc 也能关掉,有多个弹层的时候还要管谁先谁后。用 JS 写的时候要挂 document 上的点击事件,要判断点击目标是不是在弹层里,要处理 focus 回到触发器上,还要考虑多个弹层之间的互斥。
这套逻辑写一遍不累,但项目里散落着七八个弹层组件,每个都在重复这些行为,维护起来就头疼了。
HTML 现在原生支持了弹层这套东西,而且支持得还挺全。这篇文章拿一个真实场景——用户头像菜单加上分享面板——把整条路走一遍。全程只写一行 JS 处理一个纯业务逻辑,其余全交给 HTML。
popover 是什么
先看最小的例子:
<button popovertarget="menu-1">打开</button>
<div id="menu-1" popover>
这是弹层内容
</div>
就这两段代码。没有 JS,没有 display: none 的手动切换,没有 addEventListener。
popover 属性加在要作为弹层的元素上,popovertarget 加在触发按钮上指向那个元素的 id。浏览器会自动处理:
- 默认状态下弹层元素
display: none,不可见 - 点击按钮,弹层出现——而且出现在浏览器计算好的”最顶层”,不会被任何
z-index盖住 - 再点按钮关闭
- 点弹层外的任何地方关闭
- 按 Esc 关闭
最后两条是重点。用 JS 手写这些行为的时候,最容易漏的就是”点外面关闭”和”重开的时候焦点应该回到按钮”。原生做这件事的时候把这两条都考虑进去了。
auto 和 manual 两种模式
popover 直接写、没有值,等于 popover="auto"。这是”轻量提示”模式,适合菜单、选择器这类临时浮层。
auto 模式下有两个自动行为:
一是点外面就关闭。这个前面说过。
二是被其他弹层”顶掉”时自动关闭。也就是说,如果页面上同时打开着两个 auto 弹层,打开第二个的时候第一个会自动关闭。所有 auto 弹层共享同一个”顶层”。
这个行为有时候是好事,有时候是麻烦。比如做嵌套弹层——从菜单里点开一个子菜单——用 auto 就会出问题:打开子菜单的时候父菜单自动关了。
这时候该用 popover="manual":
<div id="parent" popover="manual">...</div>
<div id="child" popover="manual">...</div>
manual 模式下,点外面不关闭,其他弹层打开也不关闭,一切都由开发者自己控制。可以理解成”这是我的弹层,什么时候关由我说了算”。
有一个新的取值 popover="hint",用途是悬浮提示。它和 auto 的关系是:auto 弹层能顶掉 hint,hint 弹层不能顶掉 auto。比如 tooltip 是 hint,用户点了按钮打开一个 auto 菜单,tooltip 就被自动清掉。这个属性目前浏览器支持还在追,可以先用着,降级到 auto 差异也不大。
按钮状态是自动更新的
还有一件事不用自己管了。当弹层打开的时候,触发按钮上会自动出现一个状态——在 CSS 里可以这样选中:
button:has(+ [popover]:popover-open) {
background: #22252a;
color: #fff;
}
/* 或者直接选中已经打开的弹层 */
[popover]:popover-open {
opacity: 1;
}
:popover-open 是个新伪类,匹配处于打开状态的弹层元素。以前做这个效果要用 JS 加一个 is-open 类,现在直接写 CSS 就行。
另外浏览器还会给触发按钮自动加 aria-expanded 和 aria-details,屏幕阅读器能正确读出状态。这些细节不用操心。
现在来做用户菜单
场景:页面顶部一个头像按钮,点击弹出一个菜单,里面有”个人资料”、”设置”、”退出登录”三项。
<button class="avatar-btn" popovertarget="user-menu">
<img src="avatar.jpg" alt="用户菜单">
</button>
<div id="user-menu" popover>
<a href="/profile" rel="external nofollow" rel="external nofollow" >个人资料</a>
<a href="/settings" rel="external nofollow" rel="external nofollow" >设置</a>
<button type="button" id="logout-btn">退出登录</button>
</div>
HTML 部分就这些。剩下的样式里要注意几件事。
定位:popover 会顶在哪里
如果没有写任何定位样式,popover 会出现在页面的正中间——因为浏览器默认给了 position: fixed 加 inset: 0。要想让它挨着按钮,得自己来:
[popover] {
margin: 0;
position: fixed;
inset: unset;
top: anchor(bottom);
left: anchor(left);
translate: 0 8px;
}
anchor(bottom) 和 anchor(left) 是 CSS 锚点定位(CSS Anchor Positioning)里的函数。它需要先声明”我这个元素锚定在谁身上”:
.avatar-btn {
anchor-name: --user-menu-anchor;
}
#user-menu {
position-anchor: --user-menu-anchor;
}
把触发按钮命名为 --user-menu-anchor,然后弹层说自己 position-anchor 是它。之后 anchor(bottom) 就表示”按钮的下边缘”。这套机制在 Chrome 系已经稳定了,Safari 和 Firefox 还在追赶。
兼容性不够理想的场景下,退回到老的写法也没问题——给弹层固定一个 top: 64px; right: 16px;,一样能用,只是页面布局变了要手动调。
防止弹层被溢出裁掉
popover 本身在顶层显示,不会被常规的 overflow: hidden 裁掉。但如果它的父容器有 contain 或者 transform 属性,浏览器会创建新的包含块,可能影响 fixed 定位的基准。一般不会碰到,但如果发现定位怪怪的,可以先检查祖先链上有没有 transform。
在弹层里做选择后关闭
点”个人资料”是一个链接,跳转即可,弹层自己会消失(页面卸载了)。点”退出登录”需要处理逻辑,同时也要关闭弹层。这时候需要一点 JS:
document.getElementById('logout-btn').addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST' });
document.getElementById('user-menu').hidePopover();
location.href = '/login';
});
hidePopover() 是标准方法。对应的还有 showPopover() 和 togglePopover()。这几个方法在 popover 元素上都可用。
新命令属性:command 和 commandfor
刚才那个退出登录的写法,其实还有更简洁的方案。新版的 HTML 加了一对属性 command 和 commandfor,专门用来表达”点击这个按钮,对那个元素做一个动作”:
<button commandfor="user-menu" command="hide-popover">关闭</button>
它和 popovertarget 的关系有点像,但通用性更强。popovertarget 只能表达”切换这个 popover”,command 可以表达更多动作。
内置的几个命令值:
toggle-popover:切换 popover 开关,和popovertarget等价show-popover:打开hide-popover:关闭show-modal:以模态方式打开 dialogclose:关闭 dialog
这套命令的好处是历史栈和事件系统统一了。用 popovertarget 的时候点击事件是”被拦截”了,JS 监听 click 会收不到。用 command 的话监听 click 一样可以拿到事件,同时在事件里还能知道触发了哪个命令。
把分享面板做成模态 dialog
继续这个场景:菜单里加一条”分享”,点开是一个带输入框的分享面板。这个面板更适合用 <dialog> 做——因为它需要一个有明确关闭动作的模态体验。
<button popovertarget="share-dialog" popovertargetaction="show">
分享
</button>
<dialog id="share-dialog">
<form method="dialog">
<h2>分享这个页面</h2>
<p>复制下面的链接发给朋友:</p>
<input type="text" readonly value="https://example.com/article/123">
<menu>
<button value="cancel">取消</button>
<button value="copy">复制</button>
</menu>
</form>
</dialog>
几个点解释一下。
popovertargetaction="show" 表示这个按钮只负责打开,不负责关闭。默认是 toggle,会把当前状态反转。show 和 hide 是另外两个可选值。
把 dialog 用 popovertarget 打开,实际上是走 showModal() 路径,不是走 show()。也就是说打开之后背景会被一个半透明的蒙层盖住,点击外面也不会关闭对话框(这是 modal 的语义——用户必须做出选择)。
<form method="dialog"> 是一个很老的技巧。表单里的按钮如果是提交按钮,点了之后 dialog 会自动关闭,按钮的 value 会作为 dialog.returnValue 传给脚本。这样连关闭逻辑都能省掉。
需要处理”复制”这个动作的时候,监听 dialog 的 close 事件:
const dialog = document.getElementById('share-dialog');
dialog.addEventListener('close', async () => {
if (dialog.returnValue === 'copy') {
await navigator.clipboard.writeText(dialog.querySelector('input').value);
}
});
退出动画和那个经典的坑
popover 和 dialog 出现的时候默认是瞬变,没有过渡动画。加上过渡不难:
[popover] {
opacity: 0;
translate: 0 8px;
transition: opacity 200ms ease, translate 200ms ease;
}
[popover]:popover-open {
opacity: 1;
translate: 0 0;
}
打开动画一开就行。因为打开的时候浏览器把 display: none 去掉,同时加上 :popover-open,两件事是同一帧完成的,过渡正常跑。
问题出在关闭。关闭的时候浏览器立刻把 display 变回 none,元素瞬间消失,过渡还没开始就结束了。
这个问题的通用解法是用 @starting-style 和 transition-behavior: allow-discrete:
[popover] {
opacity: 1;
translate: 0 0;
transition:
opacity 200ms ease,
translate 200ms ease,
overlay 200ms ease allow-discrete,
display 200ms ease allow-discrete;
}
@starting-style {
[popover]:popover-open {
opacity: 0;
translate: 0 8px;
}
}
[popover]:not(:popover-open) {
opacity: 0;
translate: 0 8px;
}
这里的关键是两个东西。display 和 overlay 都是离散属性,默认不做过渡,值直接切换。加 allow-discrete 之后,浏览器会等整段过渡时间走完再切换最终值。而 @starting-style 用来给”元素第一次出现之前”设定初始状态,让打开那一刻能从 0 透明度开始。
写的时候容易漏掉 overlay 这一项。overlay 是个特殊的 CSS 属性,控制元素是否处于”顶层”。想让它退出的时候保持视觉在场,也必须让它参与过渡,否则视觉上会直接消失。
嵌套弹层里那个自动关闭的坑
再回到那个用户菜单。假设菜单里”设置”项点开之后应该弹出一个新的二级菜单——按前面的说法,两个都用 popover="auto" 的时候,打开新的会自动关掉旧的。
那怎么办?把父菜单设为 manual 就行:
<div id="user-menu" popover="manual">
...
<button popovertarget="settings-menu">设置</button>
</div>
<div id="settings-menu" popover>
...
</div>
父是 manual,自身不参与自动关闭。子是 auto,点外面的时候子和父一起关(因为 auto 的点外面关闭逻辑是全局的,会清掉所有 auto 弹层)。但父是 manual,点外面不会自动关——这一点需要写 JS 处理,或者干脆父也设成 auto,接受”打开子菜单父菜单会关闭”这个行为。
后一种情况下用户会觉得菜单跳走了,所以一般还是用 manual 加手动关闭逻辑。
键盘和焦点
这块原生做了很多,但有两个细节还是得留意。
第一个是焦点顺序。<dialog> 打开的时候浏览器会自动把焦点放到第一个可聚焦的子元素上。如果希望焦点放在特定位置,可以给那个元素加 autofocus:
<dialog id="share-dialog">
<input type="text" autofocus readonly value="...">
...
</dialog>
第二个是关闭后的焦点。按 Esc 关闭 dialog 的时候,焦点会自动回到触发它的元素上。这个行为是浏览器默认给的,不需要自己处理。用 JS 手动 dialog.close() 的时候也会遵循同样的规则——只要那个触发元素还在 DOM 里、还可以聚焦。
如果触发元素在关闭的过程中被移除了(比如点了”退出登录”,页面要跳转),焦点就无处可去。这种场景下最好显式处理一下,让焦点落到一个稳定的元素上。
兼容性现状
popover 这套东西现在落地情况挺好了。Chrome、Edge、Safari、Firefox 都已经进正式版本很久了。主要卡在几个新一点的 API 上。
完整支持:popover 属性、popovertarget、showPopover/hidePopover/togglePopover 方法、:popover-open 伪类、dialog 的 showModal。这些可以放心用。
部分支持:popover="hint" 和 command/commandfor,主要在 Chrome 和较新的 Chromium 内核浏览器里。它们的行为差异不影响核心体验——hint 和 auto 差别有限,command 可以直接用 popovertarget 代替。
支持较弱:CSS 锚点定位(anchor() 和 anchor-name)。用的地方最好加个静态定位的兜底。
降级方案其实很好写,因为 popover 本身的 fallback 就是 display 状态:
@supports not selector(:popover-open) {
/* 老浏览器里给按钮加个 click 监听,手动切 display */
}
但老实说,能完全识别 popovertarget 的浏览器已经不老了,如果不是要支持三四年前的设备,直接上就行,不用裹一层兜底。
完整代码
把前面的片段拼起来。这个版本可以直接另存为 HTML 打开运行。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>用户菜单示例</title>
<style>
:root {
--brand: #3b82f6;
--text: #1f2328;
--muted: #6b7076;
--line: #e4e6eb;
--radius: 12px;
}
body {
margin: 0;
min-height: 100vh;
font: 15px/1.6 -apple-system, "Segoe UI", "PingFang SC", sans-serif;
color: var(--text);
background: #f7f8fa;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid var(--line);
}
.brand { font-weight: 700; letter-spacing: -0.01em; }
.avatar-btn {
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
color: #fff;
font-weight: 600;
cursor: pointer;
anchor-name: --user-menu-anchor;
}
.avatar-btn:has(+ [popover]:popover-open) {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
/* ==== 用户菜单 ==== */
#user-menu {
min-width: 200px;
padding: 8px;
margin: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
box-shadow: 0 12px 32px -12px rgba(20, 22, 26, 0.18);
position: fixed;
inset: unset;
position-anchor: --user-menu-anchor;
top: anchor(bottom);
right: anchor(right);
translate: 0 8px;
opacity: 1;
transition:
opacity 200ms ease,
translate 200ms ease,
display 200ms ease allow-discrete,
overlay 200ms ease allow-discrete;
}
#user-menu:not(:popover-open) {
opacity: 0;
translate: 0 0;
}
@starting-style {
#user-menu:popover-open {
opacity: 0;
translate: 0 0;
}
}
#user-menu a,
#user-menu button {
display: block;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 8px;
background: none;
text-align: left;
font: inherit;
color: var(--text);
text-decoration: none;
cursor: pointer;
}
#user-menu a:hover,
#user-menu button:hover {
background: #f0f2f5;
}
#user-menu button.danger { color: #d92d20; }
/* ==== 分享对话框 ==== */
#share-dialog {
width: min(420px, calc(100vw - 32px));
padding: 24px;
border: none;
border-radius: var(--radius);
box-shadow: 0 24px 48px -16px rgba(20, 22, 26, 0.24);
}
#share-dialog::backdrop {
background: rgba(20, 22, 26, 0.4);
backdrop-filter: blur(2px);
}
#share-dialog h2 {
margin: 0 0 8px;
font-size: 18px;
}
#share-dialog p {
margin: 0 0 16px;
color: var(--muted);
font-size: 14px;
}
#share-dialog input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
font: inherit;
font-size: 14px;
box-sizing: border-box;
margin-bottom: 16px;
}
#share-dialog menu {
display: flex;
justify-content: flex-end;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
#share-dialog button {
padding: 8px 16px;
border-radius: 8px;
border: 1px solid var(--line);
background: #fff;
font: inherit;
cursor: pointer;
}
#share-dialog button[value="copy"] {
border-color: var(--brand);
background: var(--brand);
color: #fff;
}
main {
max-width: 640px;
margin: 64px auto;
padding: 0 24px;
}
</style>
</head>
<body>
<header>
<div class="brand">示例站点</div>
<button class="avatar-btn" popovertarget="user-menu">王</button>
</header>
<div id="user-menu" popover="manual">
<a href="/profile" rel="external nofollow" rel="external nofollow" >个人资料</a>
<a href="/settings" rel="external nofollow" rel="external nofollow" >设置</a>
<button type="button" popovertarget="share-dialog" popovertargetaction="show">分享</button>
<button type="button" class="danger" id="logout-btn">退出登录</button>
</div>
<main>
<h1>这是一篇示例文章</h1>
<p>点击右上角的头像可以打开用户菜单。</p>
</main>
<dialog id="share-dialog">
<form method="dialog">
<h2>分享这个页面</h2>
<p>复制下面的链接发给朋友:</p>
<input type="text" readonly autofocus value="https://example.com/article/123">
<menu>
<button value="cancel">取消</button>
<button value="copy">复制</button>
</menu>
</form>
</dialog>
<script>
const dialog = document.getElementById('share-dialog');
dialog.addEventListener('close', async () => {
if (dialog.returnValue === 'copy') {
const input = dialog.querySelector('input');
try {
await navigator.clipboard.writeText(input.value);
} catch (err) {
input.select();
document.execCommand('copy');
}
}
});
document.getElementById('logout-btn').addEventListener('click', async () => {
document.getElementById('user-menu').hidePopover();
alert('这里会发起退出请求');
});
</script>
</body>
</html>
把这段存成 menu.html 用 Chrome 或 Safari 打开就能看到效果。因为 position-anchor 那几行在有些浏览器版本里还不支持,如果发现菜单位置不对,可以把这几行删掉,退化成给 #user-menu 写死一个 top: 64px; right: 24px;。
最后说两句
popover 和 dialog 这类原生元素的意义不在于”省几行代码”。它们把一批本来每个项目各写一遍的东西——点外面关闭、焦点管理、顶层渲染、Esc 关闭——集中到了浏览器里实现。
这套行为在浏览器里实现一次,就意味着后续所有网站都能用上同一个版本,也从另一个角度降低了各网站重新犯错的可能。用 JS 手写十个弹层组件,有八个会在某个场景下忘记把焦点还回去。原生弹层不会。
唯一要提醒的是:这类特性的兼容性不像 fetch 或者 Promise 那样能一眼看出缺了什么。老浏览器上写 popover 不会报错,只是弹层永远出不来或者关不掉。上线之前最好在目标浏览器的最高版本和最低版本上各点一遍。

