配了三年浮层效果,每次调位置都要靠 JS 算坐标——今天 CSS anchor() 把这件事彻底原生化了
配了三年 tooltip,每次调位置都要靠 JS 算坐标——今天 CSS anchor() 把这件事彻底原生化了
每个写过 tooltip 或下拉菜单的前端,大概都掉进过同一个坑:给触发按钮量位置、加 scroll 偏移量、setPosition fixed、再监听 scroll 和 resize——整个过程行数不少,还容易出 bug。Floating UI(也就是曾经的 Popper.js)就是为了解决这个问题诞生的,它帮你封装了所有坐标计算的脏活累活。但它本质上还是 JS:每次算位置都要在微任务队列里跑一遍,触发一次 layout reflow。
这件事,CSS 自己做了一遍。
三年翻车的坑,都长什么样
最经典的 tooltip bug 大概长这样:
const rect = button.getBoundingClientRect();
tooltip.style.top = rect.bottom + window.scrollY + 'px';
tooltip.style.left = rect.left + rect.width / 2 + 'px';
结果:tooltip 在视口边缘被截断了、手机滚动后位置飘了、触达边界时需要手动写翻转逻辑……每加一个功能,就多一层 if-else。这不是代码写得好不好的问题,是 JavaScript 本来就不应该管坐标计算这件事——它没有足够的信息在 layout 之前就知道最终位置。
Floating UI 的 computePosition() 封装了这些,但它依然在 JS 里跑,每次调用都会触发一次 layout reflow,在低端机上滚动时能感觉到掉帧。
CSS Anchor Positioning 是什么
CSS Anchor Positioning 是浏览器原生的 API,核心思想很简单:让一个元素(定位元素)”拴”在另一个元素(锚点)旁边,所有坐标计算由浏览器的 layout 引擎在 paint 之前完成,JavaScript 零介入。
三件套:
/* 1. 给触发按钮起个名字 */
.trigger-button {
anchor-name: --my-tooltip;
}
/* 2. 让浮层"认"这个锚点 */
.tooltip {
position: absolute;
position-anchor: --my-tooltip;
/* 3. 拴在锚点的特定边上 */
top: anchor(bottom); /* tooltip 的顶边 = 按钮的底边 */
left: anchor(center); /* tooltip 水平居中 */
transform: translateX(-50%);
margin-top: 8px;
}
anchor() 函数返回锚点某个边的像素坐标,可以参与 calc() 运算。anchor(center) 是锚点在该轴的中点。还有一个实用技巧:anchor-size(width) 让浮层和触发按钮等宽——select 类下拉菜单的常见需求,一行 CSS 搞定。
关键点:两个元素不需要是兄弟节点,不需要共享父级,不需要任何 DOM 关系。 浏览器在 layout 阶段靠名字解析这个连接关系,在此之前 paint 不会触发,JS 也不会执行,没有任何 layout thrash。
另一个工具:position-area 网格定位
除了 anchor() 函数,还有一套等价的网格 API:position-area。
把锚点当作 3×3 网格的中心格,position-area 可以让浮层”占”其中一个格或跨越多个格:
.tooltip {
position: absolute;
position-anchor: --my-tooltip;
position-area: top; /* 浮层在锚点上方 */
}
top/right/bottom/left/center 以及 span 组合(top span-right 等)可以精确描述浮层和锚点的空间关系。这个写法的可读性比 anchor() 更高,适合复杂的对齐需求。
第三个工具:@position-try 自动翻转
这是最有价值的一个。
做一个下拉菜单,默认想把菜单放在按钮下方。但当按钮靠近视口底部、菜单内容很长时,菜单会被截断。传统解法是 JS 监听溢出、手动加翻转 class。CSS Anchor Positioning 给你一套声明式的备选机制:
@position-try --menu-below {
position-area: top; /* 改到锚点上方 */
margin-top: 8px;
}
.dropdown {
position: absolute;
position-anchor: --my-button;
position-area: bottom; /* 默认:菜单在按钮下方 */
position-try-fallbacks: --menu-below; /* 放不下就自动切换 */
}
position-try-fallbacks 里可以写 flip-block / flip-inline / flip-start 这些内置关键字,也可以用 @position-try 自定义。浏览器自动遍历这些备选位置、找到第一个不溢出的,零 JS。
迁移路径:三步走
第一步:识别最简单的那几个组件。 tooltip、基本的 popover、没有任何子级菜单的 dropdown,从这几个开始迁,风险最低。
第二步:加上 position-anchor 和 anchor()。
/* 旧:Floating UI */
.tooltip { position: absolute; }
/* 新:原生 */
.tooltip {
position: absolute;
position-anchor: --my-tooltip;
top: anchor(bottom);
left: anchor(center);
transform: translateX(-50%);
margin-top: 6px;
position-try-fallbacks: flip-block;
}
第三步:@supports 渐进增强。
@supports (anchor-name: --x) {
/* CSS Anchor Positioning 版本 */
.tooltip { position: absolute; position-anchor: --my-tooltip; ... }
}
不支持的浏览器继续走旧的 JS 路径,不影响功能。现代浏览器走 CSS 路径,零 JS 代价。不需要任何 polyfill。
对比 Floating UI:哪个更好
| CSS Anchor Positioning | Floating UI (computePosition) | |
|---|---|---|
| 包体积 | 0 KB | ~6 KB (gzipped) |
| 定位计算时机 | layout 之前,paint 之前 | 微任务队列,触发 layout reflow |
| 滚动时重算 | 浏览器自动,无 JS 执行 | rAF 循环,JS 持续跑 |
| 复杂翻转逻辑 | flip-block / @position-try | middleware flip() |
| 跨 Shadow DOM | 不支持 | 支持 |
| 虚拟列表场景 | 不支持 | 支持 |
结论:对于 tooltip、简单 popover、dropdown,CSS Anchor Positioning 就是最优解。Floating UI 的价值在于:虚拟列表里锚点滚动路径不确定的情况、跨 Shadow DOM 的嵌入场景、以及某些需要精确 cursor-based 定位的交互。两者不是”非此即彼”,而是按场景各司其职。
浏览器支持情况
Chrome 125+ / Edge 125+ / Firefox 131+ / Safari 18.2+,全球覆盖率 ~88%,Baseline 2026 已确认。
Safari 18.2–18.3 的一个坑:支持 anchor() 和 position-anchor,不支持 @position-try。解法:把基础定位写在 @position-try 外面——Safari 会显示基础位置(不翻转),Chrome/Firefox/Safari 18.4+ 获得完整翻转行为。这个 gap 对大多数产品来说可以接受。
社区 polyfill @oddbird/css-anchor-positioning(约 8 KB gzipped)可以在需要时按需加载:
if (!CSS.supports('anchor-name', '--a')) {
const script = document.createElement('script');
script.src = 'https://unpkg.com/@oddbird/css-anchor-positioning';
document.head.appendChild(script);
}
现代浏览器零代价,只有旧浏览器才下载 polyfill。
现在可以做什么
如果你的项目还在用 Popper.js 或 Floating UI,第一步:先找一个最简单的 tooltip,用上面的三步路径走一遍,体验整个过程,感受零 JS 的手感。第二步:在 CI 里加一条 lighthouse bundle 审计,把 computePosition 相关的包删掉,看有没有命中率的改善。第三步:把 @supports 渐进增强写进团队的 CSS 规范,以后新浮层组件默认走 CSS 路径。
浮层定位这件事,本来就不应该由 JavaScript 来管。浏览器知道每个元素在哪里、视口剩多少空间、用户滚动到哪里——让它算,比让 JS 算更准、更快、更省电。现在这件事终于可以交给浏览器了。
评论区
登录后可评论。