配了五年浮层防溢出,今天才知道这件事从来不需要 JS——@position-try 把它彻底原生化了

写过浮层 UI 的人都踩过这个坑——下拉菜单弹出来跑到视口外面去了,以前只能靠 Popper.js/Floating UI 在 JS 里算 getBoundingClientRect() 然后 requestAnimationFrame 循环重算。今天这件事被 CSS @position-try 彻底原生化了。

核心问题:浮层定位为什么非靠 JS

浮层 UI(tooltip、下拉菜单、select 弹层)天然面临一个问题:它的位置取决于它自己的尺寸,而它的尺寸取决于它显示的内容——但内容是渲染完了才知道的。所以要么用 JS 读渲染后的尺寸再重算,要么在 CSS 里写死尺寸然后祈祷不溢出。

@position-try 解决的就是这个「等渲染完才知道往哪翻」的问题。它的工作方式很有意思:声明一批备用定位方案,浏览器在布局阶段自己决定用哪个——整个过程在浏览器布局引擎里完成,不需要任何 JS。

内置 flip 关键字:一行替代 Popper.js 的 flip middleware

最常见的场景是「往上跑不下就往下」。用内置关键字一行搞定:

.dropdown {
  position: fixed;
  position-anchor: --trigger;
  position-area: top center;
  margin-bottom: 8px;
  /* 往上放不下?试试往下。往下也放不下?试试往左往右翻 */
  position-try-fallbacks: flip-block, flip-inline;
}

flip-block 镜像 block 轴(top↔bottom),flip-inline 镜像 inline 轴(left↔right)。浏览器按顺序试,第一个不出界的就用了。没有任何 ResizeObserver,没有任何 scroll 监听,没有任何数学计算。

flip-start 是逻辑方向版,在 RTL 语言下自动镜像到正确方向,多语言产品直接受益。

自定义 @position-try:说清楚「什么情况换哪里」

内置关键字只能做镜像。如果你想「正常放下面,横向放不下就侧到右边去」,需要自定义块:

@position-try --flip-below {
  position-area: bottom center;
  margin-top: 8px;
  margin-bottom: 0; /* 必须重置,否则继承主规则的 margin-bottom */
}

@position-try --to-right {
  position-area: right center;
  margin-inline-start: 8px;
  margin-bottom: 0;
}

.dropdown {
  position: fixed;
  position-anchor: --trigger;
  position-area: top center;
  margin-bottom: 8px;
  position-try-fallbacks: --flip-below, --to-right;
}

注意每个 @position-try 块里要独立写清楚所有尺寸和间距,不要依赖主规则的值继承——布局属性会跨块累加,不重置就会跑偏。

position-try-order:让空间最大的方案胜出

默认按你写的顺序选第一个不出界的。如果 UI 区域是不规则形状(侧边栏里的下拉),可以用 position-try-order 改成「哪个方向空间最大用哪个」:

.panel {
  position-try-order: most-height; /* 优先选垂直空间最充裕的方案 */
  position-try-fallbacks: flip-block, flip-inline;
}

逻辑关键字 most-block-size / most-inline-size 和物理关键字 most-height / most-width 都可以用。

配合 inset-area:常见位置用网格语法更简洁

position-area 用网格组合描述位置,比 anchor() 函数更直观:

.tooltip {
  position: fixed;
  position-anchor: --btn;
  inset-area: top center; /* 等同于 top center 定位 */
}

可组合的方向值:top/bottom/left/right + center,可以加 span-left/span-right/span-top/span-bottom 做宽度延伸。精确间距还是要靠 anchor() + calc()

配合 Popover API:零 JS 的完整浮层方案

@position-try 解决的是「放哪」的问题,popover 属性解决的是「怎么显示/隐藏」的问题。两者配合就是完整方案:

<button popovertarget="menu">菜单</button>
<ul popover id="menu">
  <li><a href="#">首页</a></li>
  <li><a href="#">设置</a></li>
</ul>
[popover] {
  position: fixed;
  position-anchor: --trigger;
  position-area: bottom span-right;
  width: anchor-size(width); /* 宽度自动匹配触发器,替代 sameWidth middleware */
  position-try-fallbacks: flip-block, flip-inline;
  position-visibility: anchors-visible; /* 触发器滚出视口时浮层也消失 */
}

弹出/关闭/点击外部关闭/Escape 关掉,全是浏览器原生行为,不需要任何 JS。anchor-size(width) 替代了 Popper.js 的 sameWidth: trueposition-visibility: anchors-visible 替代了滚动容器内的显式隐藏逻辑。

兼容性:Baseline 2026,91%+ 全球覆盖

Chrome 125+、Safari 26+、Firefox 131+ 已全部稳定,2026 年 1 月达成 Baseline 2026 全球覆盖约 91%。popover 属性支持更早(Chrome 114/Safari 17/Firefox 125),独立使用也完全没问题。

position-try-order: most-height/most-width 目前 Firefox 还未支持,如果用在 Firefox 上会回退到顺序选择。position-visibility: anchors-visible Firefox 也在跟进。核心的 position-try-fallbacks + @position-try 四大引擎全部稳定,可以直接上线。

怎么迁移

新项目直接用。对于已有 Popper.js / Floating UI 的项目,可以逐个组件替换:

  1. position 改成 fixed + position-anchor
  2. getBoundingClientRect + 手动坐标改成 position-area
  3. 把 flip middleware 的 JS 逻辑改成 position-try-fallbacks: flip-block
  4. sameWidth 改成 width: anchor-size(width)
  5. 把滚动容器内的显示/隐藏监听改成 position-visibility: anchors-visible

实测一个带 4 个 fallback 方案的下拉菜单,从 Popper.js 迁移后:CSS 6 行,JS 0 行,bundle 减少约 12KB,运行时重算完全消失(devcheolu.com 实测数据)。

下一步

如果你在用 React/Vue 组件库,先查文档里浮层组件是否已支持 anchor positioning——Headless UI、Ark UI 都在跟进。如果不支持,考虑给库提 PR,或者在业务代码里先用 CSS 原生方案,等库更新。

这个 API 背后真正有意思的是:它把「几何计算」这件事从 JS runtime 挪回了浏览器布局引擎——在 JS 里算坐标再写回 DOM,绕了一大圈,浏览器其实一开始就知道答案,只是没有接口让 CSS 声明「我需要知道什么时候会溢出」。现在有了。

评论区

0 条评论

登录后可评论。

阿柯·前端架构 15 阅读