两年前接了一个后台管理项目,技术栈是原生 HTML 加一点 Alpine.js。需求里出现频率最高的组件就是“弹个框”:删除确认、导出参数、批量操作的二次确认。当时第一反应是找个成熟库,看了一圈,不是体积太大就是依赖 Vue 或者 React 的运行时,硬塞进原生页面里很别扭。
后来决定自己写。写到最后发现,模态框最难的那部分——焦点困在框里、背景不能滚动、ESC 关闭、点击遮罩关闭——全都是浏览器早就内置好的能力,我只需要把 showModal() 调对地方就行。再往后做下拉菜单的时候又遇到了 popover 属性,两个东西合起来,几乎把我之前写的 600 多行弹窗代码全部删掉了。
这篇把这两块拆开讲清楚,顺带把踩过的坑记下来。
一、dialog 元素的基础,比想象中多不少细节
最朴素的一个对话框长这样:
<dialog id="dlg">
<p>这是一段说明文字。</p>
</dialog>
默认是隐藏的,直接打开页面看不到它。要显示,得走 JS:
const dlg = document.getElementById('dlg');
dlg.show(); // 非模态
dlg.showModal(); // 模态
showModal() 和 show() 的差别,是我认为 dialog 元素最值钱的地方。模态模式下浏览器会自动做四件事:
- 把元素提升到一个叫顶层(top layer)的地方,无论父级有没有
overflow: hidden、有没有transform造成的层叠上下文,都不会被裁剪或挡住 - 插入
::backdrop伪元素,也就是那层半透明遮罩 - 把页面其余部分的交互屏蔽掉,同时阻止背景滚动
- 把键盘焦点圈死在对话框内部,Tab 键不会跑到后面的页面上去
第四点最容易被低估。自己用 div 搭模态框,焦点管理要写不少代码:打开时记录当前焦点、把焦点送到框里第一个可聚焦元素、监听 Tab 键做循环、关闭时把焦点还给触发按钮。这四步漏一步,键盘用户就会骂人。dialog 用了 showModal() 之后,浏览器全包了,关闭时焦点还会自动落回原来触发它的那个元素上。
非模态的 show() 不锁任何东西,就是单纯把元素显示出来,跟一个普通 div 没区别,位置也还在原来的文档流里。
二、backdrop 的样式,以及那个经典的滚动条问题
遮罩层用 ::backdrop 来控制:
dialog::backdrop {
background: rgb(0 0 0 / 0.45);
backdrop-filter: blur(2px);
}
这里有个真实被测试提过 bug 的地方。模态打开之后页面滚动被禁止,滚动条消失了,整个页面会向右“跳”一下,看起来像是内容抖了一下。解决思路是给根元素补上滚动条宽度的内边距:
html {
scrollbar-gutter: stable;
}
一行搞定,比用 JS 去算 window.innerWidth - document.documentElement.clientWidth 再手动补 padding 干净太多。这个属性在 Chrome 94 之后就有了,现在可以放心用。
三、form method=”dialog”,少写一半的 JS
一个确认框,如果只是想知道用户点了“确定”还是“取消”,完全不需要自己绑 click 事件:
<dialog id="confirm-dlg">
<form method="dialog">
<p id="confirm-text">确定要删除这条记录吗?</p>
<button value="cancel">取消</button>
<button value="ok">删除</button>
</form>
</dialog>
表单的 method 设成 dialog 之后,提交动作不会发请求,而是直接关闭所在对话框。哪个按钮触发的提交,它的 value 就会成为对话框的 returnValue。按钮默认就是 type="submit",不需要额外标注。
配套的 JS 逻辑很短:
const dlg = document.getElementById('confirm-dlg');
const textEl = document.getElementById('confirm-text');
function confirmAction(message) {
if (dlg.open) return Promise.resolve('');
dlg.returnValue = ''; // 清掉上一次的结果
textEl.textContent = message;
dlg.showModal();
return new Promise(resolve => {
dlg.addEventListener('close', () => resolve(dlg.returnValue), { once: true });
});
}
// 用法
const result = await confirmAction('这条记录删掉之后无法恢复。');
if (result === 'ok') {
// 执行删除
}
这里有三个坑,都踩过。
第一,dlg.returnValue 不会自动重置。用户第一次点了“确定”,第二次再打开时如果直接按 ESC 关闭,returnValue 仍然停留在上次的 "ok",你的代码会以为用户又确认了一遍。所以每次 showModal() 之前手动把它清空。
第二,重复调用 showModal() 会抛异常。如果对话框已经是打开状态,再调一次会得到一个 InvalidStateError。所以在函数开头加一句 if (dlg.open) return 做保护。上面代码里返回了一个空字符串来短路,实际项目中也可以直接抛错或者忽略。
第三,ESC 关闭时 returnValue 是空字符串。这一点其实是好事,只要判断 result === 'ok' 就自然不会误判。但如果你的逻辑是“只要不是取消就算确认”,那就危险了——用户按 ESC、按浏览器返回、点击遮罩,都会走到这个分支。建议反过来判断,只认白名单里的值。
四、cancel 事件:拦一下 ESC
有时候业务上不希望用户随手按 ESC 就把框关掉,比如表单里已经填了一堆内容。这时候可以监听 cancel 事件,它是可以取消的:
dlg.addEventListener('cancel', (e) => {
if (hasUnsavedInput()) {
e.preventDefault(); // 拦住这次关闭
showInlineHint('内容还没保存,再按一次 ESC 才关闭');
}
});
cancel 只在按 ESC 的时候触发,点按钮关闭不会触发它。这个区别要记住,别把它当成通用的“即将关闭”钩子。
另外,点击遮罩关闭是 dialog 元素的默认行为之外的能力,浏览器不会自动帮你做。想实现的话,在 dialog 上监听点击,然后比较点击坐标和元素矩形:
dlg.addEventListener('click', (e) => {
if (e.target !== dlg) return; // 点在内容上,忽略
const r = dlg.getBoundingClientRect();
const inside =
e.clientX >= r.left && e.clientX <= r.right &&
e.clientY >= r.top && e.clientY <= r.bottom;
if (!inside) dlg.close('backdrop');
});
之所以要判断坐标而不是简单地比较 e.target === dlg,是因为 dialog 自身有 padding,点在 padding 区域上 target 也是 dlg,不判断坐标就会误伤。
不过我对遮罩关闭这个交互一直持保留态度。如果是需要用户明确决策的场景(比如删除确认),让误触变得太容易不是好事。具体看业务,别为了“符合直觉”而牺牲安全性。
五、popover 属性,另一个方向的浮层
dialog 解决的是“打断用户、必须先响应”的场景。popover 解决的是另一类:下拉菜单、工具提示、快捷操作面板、通知条。这些东西的共同点是——用户不处理它也没关系,点别处它就该自己消失。
用法是把属性直接写在元素上:
<button popovertarget="user-menu">账号</button>
<div id="user-menu" popover>
<a href="/profile" rel="external nofollow" >个人资料</a>
<a href="/settings" rel="external nofollow" >设置</a>
<button id="logout">退出登录</button>
</div>
两行 HTML,没有 JS,点击按钮菜单就出来了,点页面其他地方自动关闭。这个自动关闭的机制有个名字叫 light dismiss。
popover 属性有两个取值,不写等于 auto:
auto:点外部关闭、按 ESC 关闭、打开新的 auto 浮层时旧的自动关闭manual:谁都不管它,只能靠 JS 调hidePopover()关掉,适合做常驻的提示条或者 toast
触发按钮上还能指定动作,默认是 toggle:
<button popovertarget="tip" popovertargetaction="show">显示提示</button>
<button popovertarget="tip" popovertargetaction="hide">隐藏提示</button>
JS 侧对应三个方法:showPopover()、hidePopover()、togglePopover(),都挂在元素上。
六、popover 的默认样式,以及必须覆盖的那部分
打开 Chrome 的开发者工具看一个 popover 元素的样式,会看到浏览器塞了这么一堆:
[popover] {
position: fixed;
inset: 0;
width: fit-content;
height: fit-content;
margin: auto;
border: solid;
padding: 0.25em;
overflow: auto;
color: CanvasText;
background-color: Canvas;
}
所以默认情况下它出现在屏幕正中央,还带着一圈边框。做下拉菜单的第一步就是把 inset 和 margin 覆盖掉,否则用 top: 100% 之类的定位完全不会生效,你会盯着屏幕怀疑人生——我当年就是这样。
#user-menu {
inset: auto;
margin: 0;
border: 1px solid #d0d7de;
border-radius: 8px;
padding: 6px;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
}
另外,popover 显示时的状态可以用 :popover-open 伪类选中,这很适合做动画:
#user-menu {
opacity: 0;
transform: translateY(-6px);
transition: opacity 0.15s, transform 0.15s, overlay 0.15s allow-discrete,
display 0.15s allow-discrete;
}
#user-menu:popover-open {
opacity: 1;
transform: translateY(0);
}
@starting-style {
#user-menu:popover-open {
opacity: 0;
transform: translateY(-6px);
}
}
这里不写 display: none 的切换是没用的——因为 popover 关闭时浏览器的 UA 样式直接给了 display: none,过渡根本不会跑。要在 transition 里加 display 并带上 allow-discrete,让离散属性的变化也能参与过渡。@starting-style 则是为了给“从无到有”那一帧提供起始值,否则元素会直接从最终状态出现,看不到淡入。
这段 CSS 第一次写的时候会觉得很绕,但只要理解了“popover 关闭时是真的 display: none”,逻辑就顺了。
七、把下拉菜单放到按钮下面:anchor positioning 与兜底
现在的问题变成了:怎么让菜单精准贴在按钮下方?
CSS Anchor Positioning 就是为这个场景设计的,Chrome 125 之后可以直接用:
#user-btn {
anchor-name: --user-btn;
}
#user-menu {
position-anchor: --user-btn;
top: anchor(bottom);
left: anchor(left);
margin-top: 6px;
inset: auto;
}
更简洁的写法是用 position-area:
#user-menu {
position-area: bottom span-right;
margin-top: 6px;
}
好处是它和“锚点”之间的关系是声明式的:按钮移动、页面滚动、容器布局变化,菜单都会跟着走,不需要任何 JS 重新计算。而且可以配 @position-try 做边界翻转——空间不够时自动跑到上方:
@position-try --flip-top {
position-area: top span-right;
margin-bottom: 6px;
}
#user-menu {
position-area: bottom span-right;
position-try-fallbacks: --flip-top;
}
问题在于浏览器支持还没铺开。写这篇文章的时候,Safari 和 Firefox 的稳定版还没跟进,所以必须准备降级方案。
降级思路很直接:用 getBoundingClientRect() 读出触发按钮的位置,手动设置 left 和 top。因为 popover 打开时处于顶层,但它的定位参照依然是常规规则,position: fixed 用的还是视口坐标,所以可以直接把 rect 的值搬过去。
const supportsAnchor = CSS.supports('position-anchor', '--x');
function placeMenu(trigger, menu) {
if (supportsAnchor) return; // 支持的话交给 CSS
const r = trigger.getBoundingClientRect();
menu.style.inset = 'auto';
menu.style.margin = '0';
menu.style.position = 'fixed';
menu.style.left = r.left + 'px';
menu.style.top = (r.bottom + 6) + 'px';
}
const btn = document.getElementById('user-btn');
const menu = document.getElementById('user-menu');
btn.addEventListener('click', () => {
// popovertarget 是浏览器自己处理的,这里只在点击后做位置兜底
requestAnimationFrame(() => placeMenu(btn, menu));
});
window.addEventListener('resize', () => {
if (menu.matches(':popover-open')) placeMenu(btn, menu);
});
用 requestAnimationFrame 包一层是因为 popover 打开时先要进入顶层,尺寸和位置才算得准。滚动的情况更麻烦一些,简单项目里可以用 position: absolute 换成相对定位,或者干脆监听滚动事件重新算——反正不支持 anchor positioning 的浏览器本来就要多写点代码,这是过渡期的代价。
八、一个容易忽略的无障碍问题
popovertarget 这种声明式写法很方便,但浏览器不会自动给触发按钮加上 aria-expanded。屏幕阅读器用户按到那个按钮时,听不到“展开/折叠”的状态提示。
补救办法是用 toggle 事件同步状态:
menu.addEventListener('toggle', (e) => {
btn.setAttribute('aria-expanded', e.newState === 'open' ? 'true' : 'false');
});
toggle 事件的 newState 是 "open" 或 "closed",不管是点击外部关闭还是 ESC 关闭都会触发,比自己在 click 里猜状态可靠得多。
如果不想写 JS,还有一个纯 CSS 的取巧写法,前提是按钮和菜单是相邻兄弟节点:
#user-btn:has(+ #user-menu:popover-open) {
/* 打开状态下的按钮样式 */
}
这只能改视觉,改不了 aria 属性,所以正式项目还是建议加上那几行 JS。
九、什么时候用 dialog,什么时候用 popover
判断标准其实只有一条:用户不处理,流程能不能继续?
不能继续,用 dialog 加 showModal()。删除确认、登录、支付密码输入都属于这一类。背景被锁、焦点被圈、必须先给出回答,这些都是应该的。
能继续,用 popover。下拉菜单、筛选面板、颜色选择器、一排快捷按钮,都属于这一类。用户点开看一眼,不想要就点别处,整个流程不被打断。
还有两个边界情况值得留意。
一是提示类的东西,比如复制成功后的 toast。用 popover 的话记得加 manual,不然它会被其他 auto 浮层挤掉,也会在用户点别处时莫名其妙消失。然后自己用 setTimeout 控制显示时长。
二是嵌套。在 popover 里再开一个 popover,浏览器是支持的,会形成一层栈,后开的压在前面,ESC 时按顺序一层层关。但 dialog 里再开 dialog 体验会糟,模态叠模态用户很容易迷路,尽量在交互设计阶段就避免。
十、踩坑清单
showModal()之前先判断dlg.open,否则重复调用会抛InvalidStateError- 每次打开确认框都要重置
returnValue,它不会自动清空 - ESC 关闭时
returnValue是空字符串,判断结果要认白名单,别写if (value !== 'cancel') - 点遮罩关闭需要自己实现,并且要判断点击坐标,不能只看
e.target - 模态打开时页面会跳一下,
html { scrollbar-gutter: stable }能解决 - popover 的 UA 样式里有
inset: 0; margin: auto;,不覆盖掉就没法自定义定位 - 想让 popover 有进出场动画,transition 里必须带上
display ... allow-discrete,配合@starting-style popovertarget不会自动同步aria-expanded,需要监听toggle事件手动加上- 需要长时间停留的提示用
popover="manual",否则会被 light dismiss 干掉 - anchor positioning 要检测支持情况,
CSS.supports('position-anchor', '--x')是最省事的判断方式
最后说一句感受。这两个 API 让我把项目里的弹窗代码从 600 多行压到了 200 行出头,删掉的几乎全是焦点管理、遮罩点击、ESC 响应这些和业务毫无关系的胶水代码。代价是浏览器的版本要求提高了——dialog 在 Chrome 37 就有了,但 popover 属性要 Chrome 114 起,anchor positioning 更晚。如果是面向国内用户、且不需要兼容老旧安卓浏览器的项目,现在就可以上;如果必须照顾旧设备,那就把 dialog 先用起来,popover 部分做个特性检测走降级路径,两边都不耽误。

