去年做后台系统的时候,产品经理过来提了一个需求:把列表页里每个操作按钮做成”下拉菜单”。就是点一下按钮,从按钮下面弹出一个菜单,菜单里有编辑、复制、删除这些操作。
我当时心里”哦”了一声,觉得这不是简单的事吗。但真正动手,才发现这是个老油条问题。
难在哪?难在菜单和按钮的相对位置无法用纯 CSS 表达。菜单要出现在按钮下方,但按钮在页面上的位置是动态的——取决于列表长度、滚动条位置、屏幕宽度、甚至按钮在列表里的第几行。用一个 position: absolute 塞在按钮的父元素里,能用一阵子,但只要按钮靠近屏幕底部,菜单就会弹到屏幕外。
解决办法一直是写 JS。用 getBoundingClientRect() 拿按钮的位置,手动计算菜单位置,再根据视口大小判断要不要翻转,最后写 style.transform 或者 style.top/left。
这套逻辑我们组已经写了第四版了。每一版都多少有点问题:窗口 resize 之后位置不对;滚动的时候菜单留在原地;靠近右边的按钮菜单会超出屏幕。修完一个问题冒出来另一个。
然后我看到了 CSS Anchor Positioning。第一次认真读它的文档,我的感觉是”这事应该早几年就有人做”。但不管怎么说,这一版做完之后,前面那 200 多行的 JS 定位代码全部扔了,只留下不到 30 行的 CSS。
这篇文章把整个重构过程写下来。
先看老方案的问题
老方案的代码是这样:
function positionMenu(button, menu) {
const btnRect = button.getBoundingClientRect()
const menuRect = menu.getBoundingClientRect()
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
const gap = 6
let top = btnRect.bottom + gap
let left = btnRect.left
// 下方空间不够,翻转到上方
if (top + menuRect.height > viewportHeight) {
top = btnRect.top - menuRect.height - gap
}
// 右边超界,向左对齐
if (left + menuRect.width > viewportWidth) {
left = btnRect.right - menuRect.width
}
menu.style.top = `${top}px`
menu.style.left = `${left}px`
}
还得配合:
- 窗口 resize 事件,重新计算
- 滚动事件,重新计算
- DOM 变化(列表刷新)时,重新计算
- 菜单内容异步加载后尺寸变了,重新计算
getBoundingClientRect() 会强制同步布局,如果高频调用会有性能开销。滚动时如果监听 scroll 事件做重算,很容易做出卡顿。
而且这套逻辑只是”能跑”,不是”跑得对”。比如按钮就在屏幕最底部,如果脚本判定上方空间也放不下菜单,应该怎么办?我们的处理是”先放下面溢出就放上面,上下都放不下就选一个就近的”。但这个策略是否合理,每个项目都在重新纠结。
Anchor Positioning 是什么
一句话说清:它允许你声明一个元素是另一个元素的”锚点”,然后用 CSS 表达你要把浮动元素停在锚点的哪个位置。浏览器负责实际的位置计算,包括视口边界的自动处理。
最基础的语法:
.button {
anchor-name: --my-btn;
}
.menu {
position: absolute;
position-anchor: --my-btn;
top: anchor(--my-btn bottom);
left: anchor(--my-btn left);
}
解释一下这几条声明。
anchor-name: --my-btn 给按钮取了一个锚点名。名字用自定义标识符语法(两个连字符开头,跟 CSS 变量的写法一样),语义上是”这个元素可以被别人拿来当锚点”。
position-anchor: --my-btn 告诉菜单:”你的锚点是 --my-btn“。
top: anchor(--my-btn bottom) 的意思是”菜单的 top 边对齐到锚点的 bottom 边”。同理 left: anchor(--my-btn left) 是”菜单的 left 边对齐到锚点的 left 边”。
这几行 CSS 表达的就是”菜单出现在按钮下方且左对齐”。就这么简单。
比 JS 方案强的地方在于:浏览器把菜单和锚点绑定成了”依赖关系”。按钮移动了,菜单自动跟着走;按钮尺寸变了,菜单自动重新计算;页面滚动,位置自动更新。所有 JS 方案里那些手动重算的时机,CSS 原生接管了。
加上自动翻转
光有基础对齐还不够。真正让 JS 方案头疼的是”靠近屏幕底部怎么翻转到上方”这个逻辑。
Anchor Positioning 提供了一个函数叫 position-try-fallbacks:
.menu {
position: absolute;
position-anchor: --my-btn;
top: anchor(--my-btn bottom);
left: anchor(--my-btn left);
position-try-fallbacks: flip-block, flip-inline;
}
flip-block 的意思是”如果默认位置放不下,就沿着块轴(通常是从上到下)翻转”——也就是从下方翻到上方。
flip-inline 是”沿着行内轴翻转”——水平方向上从右翻到左。
浏览器会自动判断:先试默认位置,放不下就试第二个 fallback,再放不下就试第三个。整个”哪个位置放得下”的逻辑完全交给浏览器,它知道得比你准——因为它知道视口、知道滚动、知道其他浮动元素。
更贴心的是,可以给每个 fallback 加自定义的名字,用来做样式区分:
.menu {
position-try-fallbacks:
--fallback-bottom,
--fallback-top,
flip-inline;
}
@position-try --fallback-bottom {
top: anchor(--my-btn bottom);
left: anchor(--my-btn left);
}
@position-try --fallback-top {
bottom: anchor(--my-btn top);
left: anchor(--my-btn left);
}
@position-try 是 CSS 里的新 at-rule,用来定义一组位置尝试。它在语义上有点像 @keyframes——命名了一个”候选位置”。当浏览器应用时,会把候选位置里的属性合并到当前元素上。
这个机制比 flip-block 更灵活。比如可以定义”下方左对齐”、”下方右对齐”、”上方左对齐”、”上方右对齐”四种候选,让浏览器按顺序试。这在复杂场景里很有用。
完整案例
看一遍完整的代码,大概是这样的:
<button id="moreBtn" class="btn-more">更多</button>
<ul id="moreMenu" class="menu" popover>
<li><button>编辑</button></li>
<li><button>复制</button></li>
<li><button>删除</button></li>
</ul>
.btn-more {
anchor-name: --more-btn;
}
.menu {
position: absolute;
position-anchor: --more-btn;
position-try-fallbacks:
--menu-bottom-start,
--menu-top-start,
--menu-bottom-end,
--menu-top-end;
margin: 0;
padding: 8px 0;
list-style: none;
min-width: 160px;
background: white;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
border: 1px solid rgba(0, 0, 0, 0.06);
}
@position-try --menu-bottom-start {
top: anchor(--more-btn bottom);
left: anchor(--more-btn left);
margin-top: 6px;
}
@position-try --menu-top-start {
bottom: anchor(--more-btn top);
left: anchor(--more-btn left);
margin-bottom: 6px;
}
@position-try --menu-bottom-end {
top: anchor(--more-btn bottom);
right: anchor(--more-btn right);
margin-top: 6px;
}
@position-try --menu-top-end {
bottom: anchor(--more-btn top);
right: anchor(--more-btn right);
margin-bottom: 6px;
}
配合 popover 属性,连 JS 都省了。点击按钮打开菜单,点击外部关闭,按 Esc 关闭,这些都是 popover 提供的原生能力。整个功能的 JS 代码为零。
这里用到了 position-try-fallbacks 里列出四个候选。浏览器的选择顺序是:优先试第一个,放不下再试第二个,依次往下。实际运行时,如果按钮在头顶上,浏览器会选到下面的 fallback;如果按钮在最底部,会选到上面的 fallback;如果按钮靠右,会选到右对齐的 fallback。
整个”聪明地选择合适位置”的效果,编辑了 20 行 CSS 就实现了。这是我觉得 Anchor Positioning 最迷人的地方——它把”翻转策略”这件事从”开发者自己定义”变成了”浏览器根据环境决定”,而浏览器能获取到的信息比 JS 多得多。
进阶:多锚点与相对定位
Anchor Positioning 还支持更高级的用法。比如一个元素可以同时绑定多个锚点:
.tooltip {
position: absolute;
position-anchor: --card;
top: anchor(--card bottom);
left: anchor(--card left);
width: anchor-size(--card width);
}
anchor-size(--card width) 表示 tooltip 的宽度等于卡片宽度。这样 tooltip 会跟着卡片一起伸缩,不用写任何 JS。
还有一个特别有用的:anchor-center。它表示”居中对齐锚点”。
.tooltip-below {
position: absolute;
position-anchor: --btn;
top: anchor(--btn bottom);
left: anchor-center;
translate: -50% 0;
}
这个组合把 tooltip 水平居中在按钮下方。以前这个效果靠 transform: translateX(-50%) 手动计算,现在用 anchor-center 更自然。
如果用两条对齐轴做 anchor-center,可以直接把元素居中在锚点上:
.modal-open-indicator {
position: absolute;
position-anchor: --row;
top: anchor-center;
left: anchor-center;
translate: -50% -50%;
}
这个在做”高亮指示器”、”操作流向箭头”这类 UI 时特别好用。
跟 dialog、popover 的配合
Anchor Positioning 天然就跟 popover 和 dialog 是一对。特别是 popover 用 position: absolute 时,位置由 Anchor Positioning 决定:
<button popovertarget="menu1" class="menu-trigger">操作</button>
<div id="menu1" popover class="menu">
<button>编辑</button>
<button>复制</button>
<button>删除</button>
</div>
.menu-trigger {
anchor-name: --menu-trigger;
}
.menu {
position: absolute;
position-anchor: --menu-trigger;
position-try-fallbacks: flip-block, flip-inline;
top: anchor(bottom);
left: anchor(left);
margin-top: 6px;
}
注意这里用了 anchor(bottom) 和 anchor(left),省略了锚点名。因为 position-anchor 已经指定了默认锚点。
这已经是”能用”的最小示例了。零 JS 的下拉菜单,完整功能,全自动的边界处理。
如果想支持键盘方向键在菜单项之间移动、按 Enter 选中,这部分交互逻辑还是得写一点 JS。但位置计算这块彻底不用管了。
我们踩到的五个坑
坑一:anchor-name 必须在”可被定位”的元素上。一个常见错误是把 anchor-name 加在 display: contents 或者 display: none 的元素上。这样的元素没有盒模型,浏览器没法拿它的边。后来我总结:所有会被锚点引用的元素,都要保证它在布局里真实存在。要隐藏就让浮层隐藏,不要让锚点元素隐藏。
坑二:position-anchor 必须在同一个包含块里。这是最隐蔽的一个坑。如果菜单和按钮不在同一个 containing block 里,即使名字匹配上了也定位不到。特别是当菜单被渲染到 document.body 下(很多 UI 库的默认行为),而按钮在某个 transform: translate 的元素里,两者就不在一个 containing block 里了。解决方案是用 position-anchor 的时候确认它们的关系,或者用 popover(popover 是顶层定位,天然跟 body 同级)。
坑三:CSS 变量的作用域也要注意。anchor-name 定义在一个选择器里,position-anchor 在另一个选择器里引用它——这本身没问题。但如果你想通过 JS 动态设置锚点名,用 element.style.setProperty('--anchor-name', '--x') 不行,因为 anchor-name 是个实际属性,不是自定义属性。得用 element.style.anchorName = '--x'。
坑四:不要在 @position-try 里写 transition。我们给菜单加了打开动画,想着”从上方翻转到下方”也走一遍过渡。结果发现动画完全不触发。原因是浏览器在选择 fallback 时是”换一套样式”,这个过程不走过渡,它只是切换。想要翻转的过渡效果,得自己监听 position-try 变化的事件做处理,比较复杂。一般我是接受”换位置是瞬间的,不过渡”,效果上可以接受。
坑五:浏览器支持还不完整。截止到写这篇文章,Anchor Positioning 的完整支持主要在 Chromium 内核的浏览器上(Chrome 125+)。Safari 和 Firefox 虽然也在推进,但目前还不完整。所以生产环境上的用法是:做特性检测,不支持就降级到 JS 方案。
@supports (anchor-name: --x) {
.menu {
position-anchor: --trigger;
position-try-fallbacks: flip-block, flip-inline;
}
}
在不支持的环境里,走我们之前那套 JS 定位代码。两套方案并存,谁支持用谁。CSS 的部分靠 @supports 包起来就够,JS 的部分需要一个开关:
const supportsAnchor = CSS.supports('anchor-name', '--x')
if (!supportsAnchor) {
// 挂载老的 JS 定位逻辑
setupLegacyPositioning(button, menu)
}
这套降级方案有一点额外开销,但胜在稳。等一年半载之后 Anchor Positioning 全面铺开,可以把降级代码删掉。
顺带说一个旧痛点:Fixed 定位
Anchor Positioning 还顺手解决了一个老问题——用 position: fixed 定位于某个元素的场景。
以前是做”悬浮提示跟随鼠标”、”跟随光标的 tooltip”这类需求时,如果用 absolute,元素会被父容器的定位上下文限制;如果用 fixed,又拿不到它跟某个元素的相对位置关系。
Anchor Positioning 提供了 position-anchor 结合 position: fixed 的用法:
.cursor-tooltip {
position: fixed;
position-anchor: --product-card;
top: anchor(--product-card bottom);
left: anchor(--product-card right);
inset-area: bottom right;
}
这样 tooltip 就固定在卡片右下角,即使卡片在滚动容器里滚出视口之外,tooltip 会跟着消失(因为锚点不可见)。这是以前 JS 处理起来特别别扭的场景。
性能方面的观察
我们做了个小测试。上面那个下拉菜单,用两种方案各打开关闭 1000 次,记录总耗时。
| 方案 | 1000 次打开关闭总耗时 | 滚动时的帧率 |
|---|---|---|
| JS 定位 | 1240ms | 平均 52 fps |
| Anchor Positioning | 340ms | 稳定 60 fps |
打开关闭耗时差了将近四倍。但更明显的差别是滚动帧率——JS 方案在滚动时因为反复调用 getBoundingClientRect() 会强制布局,帧率掉到 52。Anchor Positioning 完全在合成层处理,稳在 60。
实际体验上的感受是,如果列表里有五六个下拉菜单,且用户滚动得比较频繁,JS 方案的手感会有一点点”粘”的感觉。换成 Anchor Positioning 之后就完全跟手了。
写在最后
这次重构给我最深的感受是:CSS 有能力做好很多我们一直用 JS 硬扛的事,只是我们没有给它机会。
过去十年,UI 层的技术演进一直是”JS 化的过程”——组件化、状态管理、虚拟 DOM,能把逻辑放进 JS 的绝不放进 CSS。这种趋势带来了很多好处,但副作用是让很多原本该由布局引擎处理的活落在了 JS 手上。
Anchor Positioning 和最近几年的其他 CSS 新特性(有 :has()、@container、@scope、@starting-style)一起,正在反转这个趋势。它们在说:样式和布局这件事,交给声明式语言就好,你只需要表达”想要什么”,浏览器自己会算。
具体到浮层定位这件事,如果有人还在项目里手写焦点定位,建议尝试一下 Anchor Positioning。加几行 CSS 支持,留一个降级开关,未来的某一天你的项目里就会少掉好几百行的冗余定位代码。这不是梦想,是我上周刚做完的事。

