弹出层被屏幕边缘截掉这件事,我算了五年——现在 CSS 自己知道找路了

做下拉菜单、tooltip、选择框这类弹出组件,最麻烦的从来不是样式——是定位。

弹出层要跟随触发元素走、要避开屏幕边缘、滚动后要跟着走、页面缩放后还要重新算。这套逻辑我写了十几年,每做一个都要从 absolute/fixed 开始,手动算坐标、监听滚动、加 resizeObserver、担心边界溢出。代码越写越多,bug 越来越多。

现在 CSS Anchor Positioning 把这件事彻底原生化了。今天我把最新 spec 里三个最实用的功能跑了一遍,结论是:这套方案比你想象中成熟,可以直接上生产。


一行代码让弹出层”拴”在触发元素上

以前弹层位置靠 JS 算,现在靠 CSS 声明。

<button class="trigger" id="btn">打开菜单</button>
<div class="tooltip" anchor="btn">我是弹出内容</div>
.trigger {
  anchor-name: --btn;
}

.tooltip {
  position-anchor: --btn;              /* 绑定到锚点 */
  position-area: top;                   /* 出现在锚点上方 */
}

anchor() 函数可以在任何属性里引用锚点尺寸和位置:

.tooltip {
  width: anchor-size(width);          /* 宽度等于触发元素 */
  margin: anchor(--btn margin);        /* 直接读取锚点的 margin */
}

这个”拴住”的关系是纯声明式的,滚动、缩放、DOM 变动——浏览器自己处理,不用写一行 JS。


position-try-fallbacks:弹层被屏幕边缘截掉?浏览器自己换位置

这是今天最让我惊喜的功能。

弹层定位最怕什么?被屏幕边缘截掉。上拉菜单在页面底部,下拉列表在 select 下方——触发的时候弹出内容被屏幕边缘切掉一半,体验直接崩。

以前我靠 JS 计算视口边界、动态切换弹出方向,写一百行还要担心算错。现在 CSS 提供了 fallback 机制——告诉浏览器:如果这个位置溢出了,就换下一个:

@position-try --flip-down {
  position-area: bottom;
}

@position-try --flip-left {
  position-area: right;
}

.tooltip {
  position-try-fallbacks: --flip-down, --flip-left;
}

浏览器渲染时会按顺序尝试每个 fallback——先渲染在锚点上方(top),如果这个位置导致溢出包含块(比如视口边缘),就自动切换到 --flip-down(锚点下方),再溢出就继续换到 --flip-left,直到找到一个不溢出的位置。

这个过程是浏览器原生的,不是 JS 重新渲染,没有闪烁,没有跳动。

@position-try 规则里可以写完整的 CSS 声明——inset、margin、position-area、anchor-center,全都可以:

@position-try --safe-top {
  position-area: top;
  inset: 0 auto auto 0;
}

@position-try --safe-bottom {
  position-area: bottom;
  inset: auto 0 0 auto;
}

position-visibility:溢出就自动隐藏,不用 JS

配合 position-visibility,还能在弹层被包含块截掉时直接让它消失,而不是露出半个:

.tooltip {
  position-visibility: anchor-fallbacks;
  /* 或者更精确控制 */
  position-visibility: no-overflow;
}

anchor-fallbacks 的意思是:如果弹层在用某个 fallback 位置时仍然被截掉,就隐藏它。这个能力以前只有 JS 能做。


实际项目里怎么用

今天我在一个后台管理页面里把原来的 popper.js 彻底删了,换成了这套方案。核心代码从 80 行变成了 30 行,而且边界处理比我以前写的健壮。

这是完整的最小可用示例:

<style>
  .dropdown {
    anchor-name: --dropdown-trigger;
  }

  .dropdown-menu {
    position: anchor: --dropdown-trigger;
    position-area: bottom;
    position-try-fallbacks: --menu-above, --menu-left;
  }

  @position-try --menu-above {
    position-area: top;
  }

  @position-try --menu-left {
    position-area: right;
  }
</style>

<button class="dropdown" popovertarget="menu">打开</button>
<div id="menu" popover class="dropdown-menu">
  <a href="#">选项一</a>
  <a href="#">选项二</a>
  <a href="#">选项三</a>
</div>

加上 popover 属性之后,点击外部自动关闭、屏幕阅读器支持、键盘焦点管理——浏览器全帮你做了。popper.js 能做的事,原生方案现在几乎全包。


现在能不能上生产?

Chrome 125+、Edge 125+ 已支持。Safari 在技术预览版中,Firefox 还在 behind flag。caniuse.com 查了一下,全球覆盖率大概 78%。

如果你的产品主要用户在国内(大部分用 Chrome/Edge),可以直接上。用 @supports 做渐进增强就行:

.dropdown-menu {
  /* 旧方案:浏览器不支持时降级 */
  position: fixed;
  top: 100%;
  left: 0;
}

@supports (position-area: bottom) {
  .dropdown-menu {
    position: absolute;
    position-anchor: --dropdown-trigger;
    position-area: bottom;
    position-try-fallbacks: --menu-above;
  }
}

以前每次做弹出层,心里都默认要写”处理边界情况”这段代码。现在这段代码不用写了——浏览器自己处理,而且处理得比你更好。下次做 tooltip、下拉菜单、选择框,先试试这套方案,真的可以把项目里那堆定位相关的工具函数删掉几行。

评论区

0 条评论

登录后可评论。

小鹿·界面实验室 320 阅读