关注 Anchor Positioning:我最想少掉的是浮层定位里的那堆测量代码

前端写浮层,表面上很简单。

按钮下面出现一个菜单,输入框旁边出现一个提示,表格操作列点一下弹出更多动作。看起来就是“跟着某个元素显示一块内容”。

但真正维护过组件库的人都知道,浮层从来不轻松。

要算触发元素位置,要处理视口边界,要跟随滚动,要处理 resize,要考虑滚动容器裁剪,还要管层级、焦点、键盘和点击外部关闭。

我以前看见这类需求,第一反应就是 Popper 或 Floating UI。成熟库当然很好用,但这也说明了一个问题:浮层定位太常见了,常见到应该有更原生的表达方式。

CSS Anchor Positioning 吸引我的地方就在这里。

我最烦的是 getBoundingClientRect 越写越多

最简单的浮层定位,大家都写过:

1const rect = button.getBoundingClientRect();
2
3popover.style.left = `${rect.left}px`;
4popover.style.top = `${rect.bottom + 8}px`;

第一次写很快。后面需求一来,就开始补:

  • 右边空间不够,往左贴。
  • 下方空间不够,往上弹。
  • 父容器滚动,要重新计算。
  • 内容高度变化,要重新计算。
  • 页面缩放,要重新计算。
  • 滚动容器里被裁剪,要换策略。

最后一个简单 Tooltip,背后可能挂着一套测量、监听和重算逻辑。

我印象最深的一次,是一个看似无害的 Tooltip 上线后偶发“飘”到屏幕外。排查半天才发现,是页面里有个祖先元素加了 transform,导致 position: fixed 的包含块变了,getBoundingClientRect 给的是视口坐标,我却按文档流的坐标在算偏移,两套坐标系一错位就跑飞了。这种 bug 不会在本地稳定复现,只在某些有动画的页面才出现,最难受。

后来我们干脆上了 Floating UI,把 flipshiftautoUpdate 这套中间件全配上,确实稳,但代价是每个浮层都要带一坨 useFloating 的 hook 和 ref 转发。一个十几行就能写完的下拉,光定位样板就占了三分之一。我心里一直有个疙瘩:定位这件事,本质上是“两个盒子的空间关系”,凭什么要用 JS 一帧一帧去追?

Anchor Positioning 想解决的就是“元素相对另一个元素定位”这件事,让 CSS 自己能表达。

1.trigger {
2  anchor-name: --menu-trigger;
3}
4
5.menu {
6  position: absolute;
7  position-anchor: --menu-trigger;
8  top: anchor(bottom);
9  left: anchor(left);
10}

这段语法的意思很直观:.menu 参考 --menu-trigger 定位,顶部对齐锚点底部,左侧对齐锚点左侧。

anchor() 这个函数我刚上手时被绕过一下。它的参数指的是“锚点元素的哪条边”,而不是当前元素的哪条边。top: anchor(bottom) 读起来像“我的 top 等于锚点的 bottom”,理解成“把我的上边贴到它的下边”就顺了。它还能带百分比和插值,比如 left: anchor(50%) 让浮层左缘对到锚点水平中点,配合 translateX(-50%) 就是居中对齐:

1.tooltip {
2  position: absolute;
3  position-anchor: --trigger;
4  top: anchor(bottom);
5  left: anchor(50%);
6  transform: translateX(-50%);
7  margin-top: 6px;
8}

更省心的是 inset-area(新版规范里叫 position-area)这个简写。不用一条条写 top/left,直接声明浮层落在锚点的哪个网格区:

1.menu {
2  position: absolute;
3  position-anchor: --menu-trigger;
4  position-area: bottom span-right; /* 落在锚点下方,向右展开 */
5}

我试下来,position-area 适合那种“方向固定”的场景,写一行就完事;遇到要精确控制几像素偏移的,还是回退到 anchor()inset 更可控。两套可以混用。

如果这种能力稳定可用,很多简单浮层就不用再为了定位写一大段 JS。

值得单独说的是边界翻转。过去靠 Floating UI 的 flip 中间件,现在 CSS 有 position-try-fallbacks(早期叫 position-try-options):声明几套备选定位,空间不够时浏览器自己挑一套能放下的。

1.menu {
2  position: absolute;
3  position-anchor: --menu-trigger;
4  position-area: bottom;
5  position-try-fallbacks: flip-block, flip-inline;
6}

flip-block 是上下翻,flip-inline 是左右翻,也可以直接列出自定义的 @position-try 规则。我第一次看到下方放不下时菜单自动翻到上方、整个过程没写一行 JS,那一下确实有点触动——这正是当年我们手写最多、最容易漏 case 的逻辑。

JS 管状态,CSS 管位置,这样分工更自然

我喜欢 Anchor Positioning 的另一个原因,是它让组件职责更清楚。

理想情况下,一个菜单组件里 JavaScript 负责打开和关闭:

1button.addEventListener("click", () => {
2  menu.toggleAttribute("data-open");
3});

CSS 负责相对触发器定位:

1.menu-button {
2  anchor-name: --menu-button;
3}
4
5.menu {
6  position: absolute;
7  position-anchor: --menu-button;
8  top: anchor(bottom);
9  right: anchor(right);
10  margin-top: 8px;
11}
12
13.menu:not([data-open]) {
14  display: none;
15}

这比在 JS 里读尺寸、写 style、监听滚动更容易维护。不是所有布局问题都应该交给 JavaScript,尤其是这种非常基础的 UI 关系。

这里有个我踩过的细节:anchor-nameposition-anchor 的名字必须在同一个层叠上下文里能找到对方,而且名字是个 dashed-ident(必须以 -- 开头),写成普通字符串不报错也不生效,调试时容易盯着看半天。还有作用域问题——多个同类组件实例如果都用同一个 --menu-trigger,浏览器会按就近原则匹配,但一旦 DOM 结构复杂,建议用动态名字区分,比如给每个实例生成 --menu-{id},避免锚点串台。

另外,浮层和锚点之间不需要 DOM 上的父子关系,这点很关键。锚点在表格第三行的某个单元格里,浮层挂在 body 末尾的顶层,两者照样能对齐。过去为了躲开 overflow: hidden 裁剪,我们常把浮层 portal 到 body,再用 JS 把坐标搬过去;现在 portal 出去之后,定位关系还能靠 anchor-name 维持,这个解耦省掉的正是最烦的那部分搬运代码。

它和 Popover API 是一条链路上的能力

我会把 Anchor Positioning 和 Popover API 放在一起看。

Popover API 更偏浮层语义:打开、关闭、顶层显示、点击外部关闭、焦点行为。

Anchor Positioning 更偏浮层位置:相对谁、在哪个方向、怎么对齐。

一个理想的轻量浮层,可能同时用两者:

1<button popovertarget="user-menu" class="avatar-button">
2  头像
3</button>
4
5<div popover id="user-menu" class="user-menu">
6  <button>个人资料</button>
7  <button>退出登录</button>
8</div>
1.avatar-button {
2  anchor-name: --avatar-button;
3}
4
5.user-menu {
6  position-anchor: --avatar-button;
7  top: anchor(bottom);
8  right: anchor(right);
9  margin-top: 8px;
10}

这里有个细节我特别喜欢:用 Popover API 的浮层会被提到顶层(top layer),天然在所有内容之上,连 z-index 都不用操心,这又干掉了一类老大难——“我的弹窗被某个 z-index: 9999 的祖先盖住了”。而 Popover 的元素本身就能当锚点的目标,配合 :popover-open 写进出动画也很顺:

1.user-menu {
2  position-anchor: --avatar-button;
3  position-area: bottom span-left;
4  opacity: 0;
5  transition: opacity 0.15s, display 0.15s allow-discrete;
6}
7
8.user-menu:popover-open {
9  opacity: 1;
10}

注意 transition 里那句 display ... allow-discrete,加上它配合 @starting-style,才能让 display: none 到显示之间的那一帧也有过渡,否则浮层会“啪”地直接出现。第一次写 Popover 动画时我在这里卡了很久,查了好一阵才知道是离散属性过渡的问题。

这个方向让我觉得 Web 平台正在补齐过去只能靠组件库解决的一些常见模式。

我不会立刻全量替换现有浮层库

虽然我很关注它,但真实项目里我不会激进。

浮层是基础交互,一旦出问题,用户会非常明显地感受到。兼容性、滚动容器、移动端、可访问性,这些都必须验证。

我比较能接受的策略是:

  • 在可控的内部后台先试。
  • 从非关键浮层开始,比如说明 Tooltip、局部帮助提示。
  • @supports 做能力检测。
  • 保留成熟 JS 定位方案作为 fallback。
  • 不一次性替换全站 Popper 或 Floating UI。
1@supports (anchor-name: --trigger) {
2  .trigger {
3    anchor-name: --trigger;
4  }
5
6  .popover {
7    position-anchor: --trigger;
8    top: anchor(bottom);
9    left: anchor(left);
10  }
11}

想在跑之前先确认浏览器认不认这套语法,最省事的就是在控制台敲一行特性检测,返回的是布尔值,所见即所得:

1CSS.supports('anchor-name', '--x');        // 支持的浏览器 → true
2CSS.supports('position-anchor: --x');       // 单参数写法,效果等价
3CSS.supports('position-area', 'bottom');    // 顺手验证 position-area

这跟上面 @supports (anchor-name: --trigger) 走的是同一套判定逻辑,只是一个写在控制台、一个写在样式里。哪个特性不确定,先在控制台敲一下,比翻文档快。

新 CSS 能力最容易让人兴奋,但越是底层交互,越要慢一点落地。

我得提醒一句兼容性现状。我不去背具体版本号(更新太快,背了也容易记错),稳妥的说法是:较新版本的 Chrome 和 Edge 已经稳定支持,Safari 与 Firefox 还相对落后——要查准确的支持矩阵,去 caniuse 搜“anchor-positioning”最直接,那里按浏览器版本列得清清楚楚。DevTools 这边也值得一提:较新版本的 Chrome DevTools 在 Elements 面板选中带 anchor-nameposition-anchor 的元素时,能直接标出锚点关系(hover 会高亮对应的锚元素),调“浮层为什么没对齐”时比纯靠坐标猜方便不少。这意味着如果你的用户里 Firefox 占比不低,纯 CSS 方案目前还不能独立扛大梁。@supports 检测能优雅降级,但要记住一点:降级不只是“样式没了”,而是浮层可能完全错位甚至盖在不该盖的地方。所以 fallback 分支里我一般不是“留白”,而是真的接回 Floating UI 的那套,宁可两套代码并存一阵,也不能让不支持的浏览器里浮层裸奔。

还有个容易忽略的点:polyfill 是有的(@oddbird/css-anchor-positioning),但它走的是 JS 模拟,等于又把测量逻辑请回来了,性能和精度都打折。我个人的态度是宁可用 @supports 做真分支,也不上 polyfill——既然要引 JS,不如直接用成熟库,混着上反而更难维护。

它解决的是定位,不是完整浮层组件

还有一点适用范围要说清楚:Anchor Positioning 只解决定位问题。

一个完整浮层还要处理:

  • 焦点管理。
  • 键盘导航。
  • 屏幕阅读器语义。
  • 点击外部关闭。
  • 滚动锁定。
  • 层级管理。
  • 动画时机。
  • 移动端形态。

这些不是一个定位 API 能自动解决的。

比如 Tooltip 是否 hover 显示还是 focus 显示,菜单是否支持方向键,表格操作浮层在移动端是否应该变成底部面板,这些仍然是组件设计问题。

所以我不会把 Anchor Positioning 理解成“可以删掉浮层组件库”。更准确地说,它可能让组件库里最重复、最容易出错的一部分定位逻辑变薄。

我最想先试的场景

如果项目环境允许,我会从这些地方试:

  • 表格操作菜单。
  • 字段说明 Tooltip。
  • 筛选条件 Popover。
  • 用户头像菜单。
  • 局部帮助提示。

这些浮层出现频率高,定位模式比较固定,业务风险相对可控。

拿表格操作菜单举例,它是我心里最理想的试点。每行一个“更多”按钮,触发器位置随行高、随滚动一直在变,过去用 JS 监听虚拟滚动重算坐标是最容易掉帧的地方。换成锚点定位后,给每行按钮一个带行 id 的 anchor-name,菜单跟着锚点走,滚动时浏览器自己更新,不用我再挂 scroll 监听:

1/* 每行按钮模板里 */
2.row-action-btn {
3  anchor-name: var(--row-anchor); /* 渲染时注入 --row-42 之类 */
4}
5
6.row-action-menu {
7  position: absolute;
8  position-anchor: var(--row-anchor);
9  position-area: bottom span-left;
10  position-try-fallbacks: flip-block;
11}

这种“位置由 CSS 维护、状态由 JS 维护”的分工,在长列表里体感差异很明显。

我暂时不会优先放到这些复杂场景:

  • 富文本编辑器浮动工具栏。
  • 低代码画布节点操作面板。
  • 虚拟列表里的跨容器菜单。
  • 拖拽过程中跟随鼠标的浮层。

复杂场景通常不只是定位,它还混着滚动、缩放、虚拟化、碰撞检测和交互状态。成熟定位库仍然更稳。

几个容易翻车的细节

把一些零碎但容易踩坑的点单独列一下,都是我自己或同事实际遇到过的。

锚点被裁剪或滚出可视区时,浮层的行为要测。锚点滚出滚动容器后,浮层并不会自动消失,它会继续“悬”在锚点本该在的位置,看起来像幽灵。需要的话得自己监听锚点可见性(IntersectionObserver 或新出的 position-visibility 属性)来决定要不要隐藏。

position-area 和手写 inset 不要混在同一条规则里硬掺。我有次同时写了 position-area: bottom 又写 top: anchor(bottom),结果两套规则互相打架,浮层位置时对时不对,排查时还以为是浏览器 bug。要么全用简写,要么全用 anchor()

记得给浮层兜一个 max-width / max-height。锚点定位本身不限制浮层尺寸,遇到长内容会一路撑到视口外。配合 anchor-size() 函数还能让浮层宽度跟着锚点走,这个做下拉菜单“宽度和输入框一样宽”特别顺手:

1.select-dropdown {
2  position: absolute;
3  position-anchor: --select-input;
4  top: anchor(bottom);
5  left: anchor(left);
6  width: anchor-size(width); /* 和触发器同宽 */
7  max-height: 320px;
8  overflow-y: auto;
9}

anchor-size() 是这里面我最喜欢的一个隐藏好货。以前“下拉宽度对齐输入框”要用 JS 读宽度再写 style,现在一行 CSS 搞定,连 resize 都不用监听。

Anchor Positioning 让我觉得 CSS 正在补组件时代真正需要的能力。

以前 CSS 很擅长描述一个盒子内部怎么排,却不太擅长描述“这个元素和另一个元素的关系”。浮层定位正好卡在这个缝里,所以我们用 JS 算了很多年。

它值得关注,也值得在可控场景里试。但我会把它当成减少定位复杂度的工具,而不是完整浮层方案。技术越新,越要把它到底能替掉哪部分、替不掉哪部分说清楚。