做一个图片画廊的「快速预览」,过去基本逃不开引一个 lightbox 库:初始化、绑定事件、自己算位置、自己管焦点、自己处理 Esc 和点击外部。麻烦的地方不在于写不出来,而在于每个项目都要重写一遍,还总有几个边界情况没覆盖到。
现在这套东西可以基本交给浏览器了。下面用一个完整的小案例,把 popover 属性、CSS 锚点定位、进场动画、hint 提示串成一条线,最后给出一份可以整段复制走的单文件代码。
一、最小的起点:一个按钮 + 一个浮层
先看最核心的两行。按钮上的 popovertarget 写目标元素的 id,目标元素挂一个 popover 属性,关系就建立好了。
<button type="button" popovertarget="ql-1">
<img src="thumb-1.jpg" alt="海边日落缩略图">
</button>
<figure id="ql-1" popover>
<img src="photo-1.jpg" alt="海边日落">
<figcaption>海边日落 · 1.2 MB</figcaption>
</figure>
注意按钮上的 type="button"。这是个很容易忽略的点:popovertarget 并不会改变按钮的默认类型,如果这个按钮恰好被放在了 <form> 里,它仍然是 submit 按钮,点一下浮层打开的同时表单也提交了,页面上就是一次莫名其妙的刷新。这个坑我踩过不止一次。
另外,popovertarget 只接受 id,不能写类名也不能写选择器,写错了不会报错,只是点了没反应。
二、auto、manual、hint 该怎么选
popover 属性不写值的时候等于 auto,另外还有两个值,行为差别挺大:
auto:点击浮层外部会关闭,按 Esc 会关闭,打开一个新的 auto 浮层会自动关掉上一个。适合绝大多数「点开一个面板」的场景。manual:上面这些自动关闭的行为全都没有,只有你自己调用hidePopover()才会关。适合需要常驻的工具栏、跟随式操作面板。hint:打开时不会抢焦点,也不会打断用户当前正在做的事。多个 hint 可以同时存在,但只要有一个 auto 浮层打开,所有 hint 都会自动收起。
做 tooltip 一类的小提示,选 hint 比其他两个都合适,因为它不会把焦点从用户正在输入的地方抢走。
三、让浮层贴着触发按钮
这里有个很好用的细节:只要浮层是通过带 popovertarget 的按钮打开的,浏览器会自动把那个按钮设为这个浮层的隐式锚点,不需要你手动写 anchor-name。
所以要做的只是告诉浏览器「贴在锚点的哪一侧」:
@supports (position-area: bottom) {
.quicklook {
position: fixed;
inset: auto;
position-area: bottom;
margin-block-start: 12px;
position-try-fallbacks: flip-block;
}
}
position-area: bottom 表示放在锚点正下方、横向居中。如果锚点离视口底部太近,浮层会溢出屏幕,position-try-fallbacks: flip-block 就是告诉浏览器「放不下就翻到上面去」。加不加这一行,在长列表滚到底部时的体验差别非常明显。
外面套的 @supports 是给还没有实现锚点定位的浏览器留的退路。检测失败时,浮层会退回浏览器默认的居中显示,位置不理想但功能完整,不会出现「卡在屏幕左上角」这种半死不活的状态。
四、进场动画:@starting-style 与 allow-discrete
浮层默认是 display: none,从「不渲染」到「渲染」这一步,普通 transition 是接不住的,得让离散属性也能参与过渡。
.quicklook {
opacity: 0;
translate: 0 -8px;
transition:
opacity .2s ease,
translate .2s ease,
display .2s allow-discrete,
overlay .2s allow-discrete;
}
.quicklook:popover-open {
opacity: 1;
translate: 0 0;
}
@starting-style {
.quicklook:popover-open {
opacity: 0;
translate: 0 -8px;
}
}
拆开说几句:
display后面跟allow-discrete,是让这个离散属性在过渡期间延迟切换,否则它会瞬间结束动画。overlay同理。浮层属于 top layer,关闭时如果 overlay 立刻移除,元素就被踢出顶层,你会看到它闪一下然后消失。加上这一行,关闭动画才能完整播完。@starting-style定义的是「元素第一次进入渲染树时的初始状态」。没有它,动画只知道终点不知道起点,直接就位了。
这里用的是 translate 这个独立属性而不是 transform,好处是不会和浮层内部元素可能存在的 transform 打架,动画本身也更容易控制。
五、给背景压暗一层
.quicklook::backdrop {
background: rgb(0 0 0 / 0);
transition: background .25s ease;
}
.quicklook:popover-open::backdrop {
background: rgb(0 0 0 / .45);
}
::backdrop 只在浮层打开时存在,所以起点颜色写成全透明就行,打开时它会自己过渡到半黑,不依赖 @starting-style。另外 auto 类型的浮层点击这层背板是可以关闭的,这已经由浏览器内置了。
六、顺手做个 hint 小提示
<button type="button" popovertarget="tip-1" popovertargetaction="show">?</button>
<div id="tip-1" popover="hint">按住 Shift 再点击可以多选</div>
#tip-1 {
position: fixed;
inset: auto;
position-area: top;
margin-block-end: 8px;
pointer-events: none;
}
@supports (position-area: top) {
#tip-1 { position-area: top; }
}
用 popovertargetaction="show" 而不是默认的 toggle,是因为 hint 不需要「再点一次收起」,用户点别处时它自然就被关掉了。加 pointer-events: none 是为了防止这个提示本身挡住鼠标,否则它盖在旁边的元素上会导致点不中。
七、完整可运行的单文件示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>图片快速预览</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 40px 24px; font: 16px/1.6 system-ui, sans-serif; background: #f4f5f7; }
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
list-style: none;
margin: 0;
padding: 0;
max-width: 900px;
margin-inline: auto;
}
.gallery button {
display: block;
width: 100%;
padding: 0;
border: 0;
border-radius: 10px;
overflow: hidden;
cursor: zoom-in;
background: #dfe1e6;
}
.gallery img {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.gallery button:focus-visible {
outline: 3px solid #3b6ef0;
outline-offset: 2px;
}
.quicklook {
width: min(88vw, 520px);
margin: 0;
padding: 0;
border: 0;
border-radius: 14px;
overflow: hidden;
background: #14161a;
color: #e9eaee;
box-shadow: 0 20px 50px rgb(0 0 0 / .4);
opacity: 0;
translate: 0 -8px;
transition:
opacity .2s ease,
translate .2s ease,
display .2s allow-discrete,
overlay .2s allow-discrete;
}
.quicklook:popover-open {
opacity: 1;
translate: 0 0;
}
.quicklook img {
display: block;
width: 100%;
height: auto;
}
.quicklook figcaption {
padding: 10px 14px;
font-size: 13px;
color: #a9adb8;
}
.quicklook::backdrop {
background: rgb(0 0 0 / 0);
transition: background .25s ease;
}
.quicklook:popover-open::backdrop {
background: rgb(0 0 0 / .45);
}
@starting-style {
.quicklook:popover-open {
opacity: 0;
translate: 0 -8px;
}
}
@supports (position-area: bottom) {
.quicklook {
position: fixed;
inset: auto;
position-area: bottom;
margin-block-start: 12px;
position-try-fallbacks: flip-block;
}
}
@media (prefers-reduced-motion: reduce) {
.quicklook, .quicklook::backdrop { transition: none; }
}
</style>
</head>
<body>
<ul class="gallery">
<li>
<button type="button" popovertarget="ql-1">
<img src="thumb-1.jpg" alt="海边日落缩略图">
</button>
<figure id="ql-1" popover class="quicklook">
<img src="photo-1.jpg" alt="海边日落">
<figcaption>海边日落 · 1.2 MB</figcaption>
</figure>
</li>
<li>
<button type="button" popovertarget="ql-2">
<img src="thumb-2.jpg" alt="雪后松林缩略图">
</button>
<figure id="ql-2" popover class="quicklook">
<img src="photo-2.jpg" alt="雪后松林">
<figcaption>雪后松林 · 980 KB</figcaption>
</figure>
</li>
</ul>
</body>
</html>
八、几个值得提前知道的坑
浮层里的图片会被提前加载。 浮层在关闭状态下是 display: none,但图片资源通常还是会被请求。如果放的是几 MB 的原图,首屏流量就白白浪费了。比较稳的做法是缩略图直接用 <img>,原图地址写在 data-src 上,在 toggle 事件里替换 src。这是整套方案里少数需要写几行 JavaScript 的地方。
top layer 会绕过父级的 overflow 和 z-index。 这既是它比传统定位方案省心的地方,也意味着你没法靠调 z-index 去控制浮层之间的叠放顺序——顺序只由打开时间决定,后开的在上。
同一时间只建议开一个 auto 浮层。 auto 之间会自动互斥,但如果你混用了 manual,就得自己管住开合,否则会出现两个浮层叠在一起谁也关不掉的情况。
九、兼容性现状
Popover API 本身的支持面已经很宽,Chrome 114、Safari 17、Firefox 125 之后都能用。CSS 锚点定位要新一些,Chromium 内核从 125 版本开始稳定支持,Safari 是后来才跟上的,Firefox 目前仍在推进中——这也是上面用 @supports 加降级的原因。
hint 取值属于这套规范里较晚落地的部分,如果你的产品需要覆盖较老的浏览器,tooltip 场景建议先保留原有实现,把它当作渐进增强,而不是唯一方案。
整体看下来,这套原生方案能覆盖掉大部分过去需要引库的浮层需求。省下的不仅是几十 KB 的依赖,更重要的是焦点管理、Esc 关闭、点击外部关闭这些细节不再需要自己维护——它们本来就应该由浏览器负责。

