后台列表页里那个「筛选」按钮,点开之后弹出的面板,以前是这么做的:打开时 getBoundingClientRect 读按钮坐标,给面板设 left/top,监听 scroll 和 resize 重算,屏幕右边放不下就手动改 left,下面是表格,滚动时还得同步更新。几十行代码,每加一个类似组件就复制一遍。
现在这套逻辑可以整段删掉。CSS 锚点定位(anchor positioning)加上 Popover API,位置关系用声明式写清楚,翻边交给浏览器自动挑,滚动跟随也是浏览器自己处理。下面用一个真实项目里的筛选面板,把值钱的细节都走一遍。
锚点定位的四个核心 API
概念很少,就四个东西。掌握了就能干活。
1. anchor-name:给锚点元素起个名字
.filter-btn {
anchor-name: --filter-btn;
}
名字必须是 CSS 自定义标识符的形式,也就是两个短横线开头。这个名字是一个全局的「锚点引用」,可以被页面上任意一个定位元素使用——不需要两个元素是父子关系,甚至可以在不同的 DOM 分支里。
这里有个容易翻车的点:如果页面上有多个元素同时声明了 anchor-name: --filter-btn,浏览器不会报错,也不会取第一个,而是取文档顺序里最后一个。所以名字最好带上组件语义,别全站复用一个 --anchor。
2. position-anchor:告诉定位元素「你挂在谁身上」
#filter-panel {
position: fixed;
position-anchor: --filter-btn;
}
设了它之后,后面写 anchor() 就可以省略那个参数。省略不掉的时候也能写全,两种写法是等价的:
/* 等价 */
top: anchor(bottom);
top: anchor(--filter-btn bottom);
3. anchor():把位置算出来
anchor(bottom) 的意思是「锚点元素的下边缘所在的坐标」,求值出来就是一个长度。所以它天然可以参与 calc():
top: calc(anchor(bottom) + 8px);
这句话读起来就是「我的上边缘,等于按钮的下边缘再往下 8 个像素」。比 JS 里算 rect.bottom + 8 直白得多。
限制也得记住:anchor() 只能用在 inset 系列属性(top/right/bottom/left 以及 inset-*)和 width/height/min-width/max-width 这类尺寸属性里。写在 margin、transform: translate()、padding 里都不会生效,而且不报错,只是静默失效。第一次用的时候我就把 translate: anchor(bottom) 写了进去,调了半小时才发现。
4. @position-try:翻边方案
这是最省事的部分。把「右边放不下就靠右对齐」「下面放不下就翻到上面」写成命名的备选位置,浏览器会自己按顺序试,挑第一个能完整塞进视口的:
#filter-panel {
position-try-fallbacks: --align-right, --above, --above-right;
}
@position-try --align-right {
left: auto;
right: anchor(right);
}
注意 @position-try 里的声明是叠加在基础样式上的,不需要把基础样式重写一遍,只写发生变化的那几条就行。
完整案例:订单列表的筛选面板
需求拆一下:
- 面板贴在筛选按钮的左下角,往下留 8px;
- 宽度至少跟按钮一样,但太窄了不好看,兜底 220px;
- 右边超出视口就改成右对齐;
- 下面空间不够就翻到按钮上方;
- 表格滚动时面板跟着按钮走;
- 按钮被滚出视口,面板自动收起来;
- 点面板外面、按 Esc 都能关。
最后两条,Popover 自带。前面五条,CSS 搞定。整段代码如下。
HTML 结构
<div class="toolbar">
<button class="filter-btn" popovertarget="filter-panel">筛选</button>
<div id="filter-panel" popover>
<label for="kw">关键字</label>
<input id="kw" type="search" placeholder="订单号 / 手机号" autofocus>
<fieldset>
<legend>状态</legend>
<label><input type="checkbox" value="paid"> 已付款</label>
<label><input type="checkbox" value="shipped"> 已发货</label>
<label><input type="checkbox" value="refund"> 退款中</label>
</fieldset>
<button type="button" id="apply">应用</button>
</div>
</div>
面板就是 toolbar 里的一个普通 div,加了 popover 属性。没有 portal、没有手动挂到 body 上。popover 打开时会被提升到 top layer,所以哪怕外层容器写了 overflow: hidden,也不会被裁掉——省掉了一个很常见的 portal 需求。
CSS
.filter-btn {
anchor-name: --filter-btn;
}
#filter-panel {
/* popover 在 top layer 里,UA 样式给了 inset:0 和 margin:auto,
这两行必须先清掉,否则后面的 left/top 会被居中逻辑搅乱 */
inset: auto;
margin: 0;
position: fixed;
position-anchor: --filter-btn;
/* 默认位置:按钮左下角,往下挂 */
left: anchor(left);
top: calc(anchor(bottom) + 8px);
/* 至少和按钮等宽,但不能窄于 220px */
width: max(anchor-size(width), 220px);
/* 锚点滚出视口,面板自动隐藏 */
position-visibility: anchors-visible;
/* 按顺序尝试备选位置 */
position-try-fallbacks: --align-right, --above, --above-right;
}
@position-try --align-right {
left: auto;
right: anchor(right);
}
@position-try --above {
top: auto;
bottom: calc(anchor(top) + 8px);
}
@position-try --above-right {
top: auto;
bottom: calc(anchor(top) + 8px);
left: auto;
right: anchor(right);
}
anchor-size(width) 取的是按钮的实际宽度,外面套一个 max() 就完成了「不小于 220px」的约束。这里没有用 min-width,是因为 width 和 min-width 同时写的时候容易被后面的规则覆盖,而 max() 是一个确定的值,读起来也更清楚。
position-visibility: anchors-visible 是这轮新特性里最被低估的一条。默认值是 always,锚点滚出视口时面板会继续悬在半空或者贴在边缘。改成 anchors-visible 之后,锚点一旦不可见,面板自动隐藏,连 IntersectionObserver 都不用写。
JS 只留三行
const panel = document.getElementById('filter-panel');
const trigger = document.querySelector('.filter-btn');
document.getElementById('apply').addEventListener('click', () => {
panel.hidePopover();
trigger.focus();
});
打开、关闭、点外部关闭、Esc 关闭和焦点归还,全是 popover 的默认行为,不用写。唯一的业务逻辑是「点应用之后把焦点还给触发按钮」,一共两行。
五个我踩过的坑
坑一:忘了清 popover 的 UA 样式
现象是面板永远显示在屏幕正中间,怎么改 left/top 都不动。原因是 UA 样式表里给 popover 元素写了 inset: 0; margin: auto;,两者一配合就是标准的居中。在 #filter-panel 里加 inset: auto; margin: 0; 就正常了。记住这是用 popover + 锚点定位时必写的一行。
坑二:position-anchor 写在非定位元素上
position-anchor 只对建立了定位的元素生效。如果忘了写 position: fixed 或者 position: absolute,整段锚点相关声明全部静默失效,控制台一片安静。所以我把 position: fixed 放在这个规则块的第一行,方便一眼确认。
坑三:锚点元素被 display: none 隐藏
有些按钮在不同权限下会隐藏。如果锚点元素是 display: none,它不生成布局盒子,anchor() 就求不出值,面板会退回到 inset 的初始值附近,看起来像飘到了左上角。用 visibility: hidden 隐藏的话盒子还在,不会有这个问题,但按钮位置会占着。这类情况最好在业务层判断:按钮不可见时别让它打开面板。
坑四:@position-try 里写了重复的属性却忘了改 left
@position-try --align-right 里只写了 right: anchor(right),但基础样式里的 left: anchor(left) 还在。结果两个方向的 inset 都生效,元素被拉伸成一个横跨按钮到视口右边缘的超宽面板。备选方案里改哪一侧,就得把另一侧显式设成 auto。
坑五:以为 anchor() 能用在任何地方
前面提过,anchor() 的作用范围被限定在 inset 和尺寸属性。想通过 transform 做动画偏移,就得先算好 inset,让 transform 只负责形变。
调试和兼容
调试很省心。DevTools 里选中面板元素,Elements 面板上会有一个锚点图标,点一下就能看到一条虚线连到锚点元素上,翻边命中了哪个 @position-try 也能在 Styles 面板里看到被划掉的声明。
兼容性不要靠记浏览器版本号,直接特性检测:
@supports not (position-anchor: --a) {
#filter-panel {
/* 老浏览器的降级:让面板退回成绝对定位,
这时它会被父容器的 overflow 裁剪,所以要保证 .toolbar 不裁剪 */
position: absolute;
inset: auto auto auto 0;
margin-top: 8px;
width: 220px;
}
}
同时还要把 .toolbar 加上 position: relative,否则绝对定位的参照物是最近的非 static 祖先,位置会乱。
这个降级方案放弃了翻边和滚动跟随,但至少能用。如果项目对老浏览器的体验要求高,还可以再补一个十几行的 JS:打开面板时读一次 getBoundingClientRect() 设 left/top,再挂 scroll 和 resize 重算。写成渐进增强的路线——CSS 能跑就完全交给 CSS,跑不了才用 JS 兜底。
什么时候别用它
三种情况我会绕开锚点定位。第一种是需要兼容较老浏览器、又不能接受降级体验的 C 端项目,这时候老老实实上成熟的浮层库更省事。第二种是弹层内容复杂、需要焦点陷阱和滚动锁定的,用 <dialog> 加 showModal() 更合适,popover 本身不提供焦点陷阱。第三种是锚点元素在某个被 transform 创建的包含块里面,又要求定位元素跟着它做复杂变换的时候,两套坐标系的换算容易出问题,不如直接用 JS 同步。
除此之外,尤其是后台系统这类浏览器环境可控的场景,这套组合拳值得替换掉手里的浮层定位代码。省下来的那几十行 getBoundingClientRect,是实实在在的维护成本。

