以前每次产品经理提“给这个按钮加个气泡提示”,我心里都会咯噔一下。加个 div、定位到按钮旁边,听起来简单,但真正做起来就是另一回事:量坐标、算边界、监听 scroll 和 resize,一套 getBoundingClientRect 组合拳下来,几十行 JS 算是少的。直到我在 Chrome 125 之后开始认真试 CSS 锚点定位(Anchor Positioning),才发现原来气泡跟随、自动翻转这些事,几行声明式 CSS 就能搞定,真能实现零 JS。但别急着把代码里的 Floating UI 删掉,锚点定位远没到“无脑香”的程度,我大半年的实测里踩到过三个真正致命的坑。
如果你正打算把 Tooltip、Popover、下拉浮层这类组件往锚点定位上迁移,我建议先看完这篇。我会把最小可跑的 Demo、三个坑的完整复现链路和修复方案都摊开讲,已经用上的朋友也应该能从中找到一些排查思路。
1. 先说清楚:传统 JS 定位到底麻烦在哪
1.1 定位气泡的原罪:“量”和“算”
用传统方式给按钮挂气泡,标准流程大概是这样的:给按钮绑定 mouseenter,拿button.getBoundingClientRect()算出按钮的几何信息,再给气泡设置绝对的 left/top,最后还要把它 append 到 body 下避免被父容器裁掉。如果按钮在一个滚动容器里,还得在 scroll 事件里重新算一遍;窗口大小一变,resize 再算一遍;内容异步加载完了,很多同学会直接 “再手动调一次坐标”。
这套流程本身不难,难的是它充斥在业务的每个角落。每个项目都在重复造轮子,而且每个轮子都有自己的脾气。比如弹出层在视口底部的时候要往上翻,在视口右侧的时候要往左翻,这套“边界判断”写起来比想象中啰嗦,一旦遇到按钮位置动态变化(折叠面板展开、表格重排、字体加载),坐标就僵硬了,不会自己跟着锚点元素走。
1.2 CSS 锚点定位的核心属性与使用逻辑
锚点定位的思路完全不一样,它不量坐标,而是声明“元素 A 要贴在元素 B 旁边”。这里有三个最核心的概念需要先记住:
anchor-name:在锚点元素上起一个名字,比如anchor-name: --save-btn;,相当于给这个元素挂了个铭牌。position-anchor:在目标元素上声明要跟随哪个锚点,比如position-anchor: --save-btn;。anchor()函数或inset-area:声明目标元素和锚点边缘的对齐关系,比如bottom: anchor(top);就是把目标的底边对齐锚点的顶边。
再加上position-try-fallbacks做“试位”:当默认位置放不下时,浏览器自动尝试翻转方向。你可以把它理解成给气泡准备了一组“备选停放位置”,第一个停不下就试第二个,像停车时找车位一样。
我最初看到这套 API 的反应是:这不就是 CSS 想把 Floating UI 的活给干了吗?事实也确实如此,它把“跟随目标”和“边界翻转”这两件事从 JS 逻辑变成了浏览器的布局职责,滚动、resize、锚点元素移动时,气泡会跟着更新,不再需要手动同步。
1.3 限定边界:它做不到“跟随鼠标”
需要先泼一盆冷水。很多人听说“零 JS 气泡跟随”,第一反应是做一个跟着鼠标光标跑的气泡。CSS 锚点定位并不解决这个问题,它是元素与元素之间的相对关系,不是“元素与坐标”的关系。
我实测时用鼠标悬停按钮显示的气泡,本质上是气泡跟着按钮走,按钮在哪,气泡就在哪。如果你真要做鼠标轨迹跟随、或者根据鼠标在按钮内的位置动态偏移高亮,那还得老老实实上 JS 的 mousemove。锚点定位解决的场景是“挂在控件边上的气泡提示、下拉浮层、Popover”,而不是“跟着光标走的标签”。
2. 最小可跑通的气泡跟随:锚点命名、inset-area、自动翻转一次到位
2.1 结构:先给锚点元素起个名字
下面这套代码是我在本地反复跑的最小 Demo,结构很简单:一个按钮,一个气泡兄弟节点。
<div class="wrap"> <button class="anchor" aria-describedby="tip">保存设置</button> <div class="tooltip" id="tip" role="tooltip">保存成功后会自动同步到云端</div> </div>CSS 部分最关键的第一步,是给按钮声明锚点名字:
.anchor { position: relative; anchor-name: --save-btn; } .tooltip { position: absolute; position-anchor: --save-btn; }这里我把按钮设了position: relative,虽然规范的锚点元素不强制要求定位,但在实际项目中这样做能避免很多“锚点位置理解偏差”的问题,算是一个稳妥习惯。position-anchor就像告诉浏览器:你去找那个叫--save-btn的铭牌,然后以它为基准。
2.2 定位:用 inset-area 或 anchor() 声明位置,再加翻转
第二步是让气泡出现在按钮上方,并预留自动翻转能力:
.tooltip { position: absolute; position-anchor: --save-btn; inset-area: top; margin-bottom: 8px; position-try-fallbacks: flip-block; }inset-area: top;的含义是把气泡放到锚点元素上方那一块区域;margin-bottom: 8px是让气泡和按钮之间留出间距。如果你的 Chrome 版本较新,可能会看到规范里已经把它改名为position-area,我实际测试时inset-area在主流支持的浏览器上都能识别,但为了稳妥,项目里也可以两个一起写,或用更基础的anchor()函数:
.tooltip { position: absolute; position-anchor: --save-btn; bottom: anchor(top); left: anchor(center); translate: -50% -8px; position-try-fallbacks: flip-block; }用bottom: anchor(top)时,气泡底边对齐锚点顶边;left: anchor(center)让气泡左边与锚点中心对齐,再配合translate: -50%往左拉回一半宽度,实现水平居中。相比inset-area来说啰嗦一点,但兼容可控,而且不用纠结属性改名。
我实测中觉得inset-area写起来最爽,但如果你要控制的间距特别精细,anchor()函数更直接。两种写法都能搭配position-try-fallbacks实现自动翻转,不需要额外写 JS 判断视口空间。
2.3 交互:用 :hover、:focus 把显隐接上
气泡默认隐藏,鼠标悬停或键盘聚焦时显示。注意别只写:hover,键盘用户也需要能看到提示,所以我把:focus一起加上:
.tooltip { opacity: 0; visibility: hidden; transition: opacity 0.15s; pointer-events: none; } .anchor:hover + .tooltip, .anchor:focus + .tooltip { opacity: 1; visibility: visible; }这里有几个细节:气泡加pointer-events: none可以避免气泡覆盖在按钮上导致鼠标事件被拦截;如果气泡和按钮在 DOM 上不是紧邻兄弟,可以用:has()选择器,或者给外层容器加:hover。比如容器整体悬停时显示气泡:
.wrap:hover .tooltip, .wrap:focus-within .tooltip { opacity: 1; visibility: visible; }:focus-within还能兼顾按钮内部子元素的键盘焦点,这一行值得养成习惯。
2.4 实测结论与“真香”点
这套效果跑起来之后,我的第一感受是:以前写几十行 JS 的活,现在真的就几行 CSS。尤其是下面这几个场景,锚点定位的体验比 JS 方案好太多:
- 按钮宽度因为文案变化而自动改变时,气泡依然稳稳定在按钮上方,不需要重新量坐标。
- 页面 resize、滚动、元素动画位移时,气泡和锚点始终保持相对关系,不会出现“气泡已经飞走了”的尴尬。
- 自动翻转非常顺滑,按钮靠近视口底部时,气泡自动跳到上方,整个过程不需要监听滚动事件。
而且这套方案在 Chrome / Edge 上已经可以支撑真实业务。只要你的用户群体浏览器版本可控,它确实能省掉一大批工具库依赖。
3. 致命坑一:transform 与 overflow 引发的“隐形气泡”
3.1 复现步骤:气泡突然消失或位置跑飞
我第一次把锚点定位用到真实项目里,就遇到了一个诡异问题:按钮气泡在页面大部分位置都正常,但只要按钮靠近某个容器边缘,气泡就会“凭空消失”。更玄的是,有时候我给外层卡片加了一个很小幅度的transform: scale(1.02)悬停效果,气泡直接偏移到完全对不上的地方。
复现代码大概是这样的结构:
<div class="card"> <button class="anchor">查看详情</button> <div class="tooltip">我是气泡</div> </div>.card { transform: translateZ(0); /* 或者 filter: drop-shadow(...) */ } .anchor { anchor-name: --anchor; } .tooltip { position: fixed; position-anchor: --anchor; inset-area: top; }如果把.card上的transform去掉,气泡就恢复正常;加回来,气泡位置立刻崩坏。另外,如果.card设置了overflow: hidden,气泡在卡片边缘时会被直接裁掉,看起来就像“消失”了。
3.2 根因:包含块(containing block)链路错位
这不是锚点定位本身的 bug,而是 CSS 定位世界里最经典的“包含块”问题。
当元素设置了transform、filter、will-change: transform、backdrop-filter等属性时,它就会成为内部position: fixed元素的包含块。也就是说,你的气泡虽然声明了position: fixed,但它不再相对于视口定位,而是相对于这个加了 transform 的祖先定位。锚点定位的计算会基于锚点元素自身的几何信息,但在包含块发生变化时,坐标参考系就错位了,于是气泡出现在一个你预期之外的位置。
overflow: hidden的情形又不一样。气泡如果用了position: absolute,包含块是最近的定位祖先元素,而overflow: hidden会把超出容器边界的内容裁掉。当锚点元素贴近容器边缘时,气泡还没完全显示就被裁了一半,视觉上就像“被吃掉了一块”。
一句话概括这个坑的本质:锚点定位让“元素找元素”变简单了,但 CSS 里决定元素最终渲染位置的“包含块链条”依然存在,transform、filter、overflow 都会影响这个链条。
3.3 修复方案与排查口诀
我试过几种比较可靠的解法,按推荐程度排序:
把气泡塞进顶层(Top Layer):给气泡加上
popover属性,它会自动进入浏览器顶层,不再受任何祖先的 overflow 裁切和 z-index 压制,同时还能继续用position-anchor做锚点定位。这是目前体验最顺的组合。把气泡放到 body 直属容器里:让气泡元素不嵌套在业务容器内部,而是作为 body 的兄弟节点,配合
position: fixed使用。这样大部分祖先的 transform 和 overflow 都不会影响它。清理祖先属性:如果无法改变 DOM 结构,就检查气泡的所有祖先有没有
transform、filter、will-change、overflow: hidden/auto/scroll。能去掉就去掉,不能去掉就换方案。
我在实践中总结了一句口诀:气泡要紧贴根,祖不能带 transform、filter、overflow、will-change。这里的“根”指 body 级容器,要么靠 popover,要么靠 DOM 结构调整,目的都是让包含块尽量简单。
提示:排查时直接在 DevTools 的 Elements 面板里选中气泡,看 Computed 里
position的实际包含块是谁,比盲猜快得多。我那天花了一小时才意识到是 transform 祖先坑的,你五分钟就能查清楚。
4. 致命坑二:自动翻转没翻对,滚动场景下锚点跑偏
4.1 复现:设置了 flip-block 但气泡仍然压边
第二个坑是我给一个页面底部的操作栏做“上方弹出”时遇到的。按钮在页面底部,气泡默认位置设为上方,按理说空间充足,不需要翻转。但我在另一个表格行内按钮上测试时,按钮上方空间很小,下方反而很空,我明明写了:
.tooltip { position-anchor: --row-btn; inset-area: bottom; position-try-fallbacks: flip-block; }期待它自动翻到上方,结果气泡不但没翻,还压着视口底部边缘,甚至产生了一个滚动条。我一开始以为自己的写法有问题,但反复确认后发现:某些情况下的自动翻转并不会像我预想的那样“智能地”考虑所有边界。
还有一种更隐蔽的情况:锚点按钮位于一个overflow: auto的滚动容器内,气泡的包含块在滚动容器之外,滚动容器滚动时,气泡位置和锚点位置发生了脱节,气泡没有跟着按钮走。
4.2 根因:fallback 的决策机制和参考系不一致
position-try-fallbacks的“试位”逻辑并不是你的业务边界计算器。它会在默认位置放不下时按顺序尝试 fallback 列表里的位置,但判断“放不下”的边界条件主要基于视口或滚动容器边界,不会智能到帮你识别“有没有被其他模块遮挡”。
更关键的是,fallback 列表里的每一项都是独立的。如果你只写flip-block,它只会沿 block 轴翻转(上下翻转),如果上下翻转之后宽度方向还是放不下,那就不会再尝试水平翻转了。很多压边问题都是“只翻转了一个轴,另一个轴还是塌的”造成的。
滚动容器里的锚点跑偏,则是参考系不一致的问题。锚点定位在计算锚点位置时会考虑滚动偏移,但如果目标元素的包含块和锚点元素的包含块不在同一个滚动上下文里,两个元素的坐标系会错开,于是出现“锚点滚走了,气泡还停留在原地”的脱节现象。
4.3 修复方案:把 fallback 列表写全,统一参考系
针对翻转不充分,我现在的习惯是,凡是需要自动翻转的气泡,fallback 列表都写全:
.tooltip { position-anchor: --row-btn; inset-area: bottom; position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline; }这表示:默认在下方,放不下就翻到上方,再放不下就水平翻转,最后上下左右四个方向都试一遍。实测中这个组合能覆盖绝大多数视口边界场景。
针对滚动容器脱节,优先让气泡和锚点在同一个“定位上下文”里。具体做法是:如果锚点按钮在一个滚动容器内,气泡也放在那个容器内,并使用position: absolute配合position-anchor,让两者的参考系统一。如果气泡必须放外层(比如不希望被容器裁掉),那就干脆用popover进入顶层,让浏览器统一处理滚动层的叠加关系。
另外值得一提的属性是position-visibility,它可以在锚点不可见时自动隐藏气泡,避免出现“按钮滚出视口了,气泡还孤零零挂在那里”的情况。不过这个属性的支持和行为我遇到的情况不多,建议在目标浏览器里做一次小验证再上。
4.4 动态尺寸导致的“不翻转”
还有一个很容易忽略的触发条件:气泡内容如果是异步加载的,或者初始为display: none,等内容渲染出来后宽度变化了,翻转结果可能还停留在“旧尺寸”的状态。
我实测时遇到过:气泡内容从“加载中”变成一段很长的提示文案后,宽度瞬间变宽,但翻转状态没有跟着更新,气泡直接超出视口。强制切换一次display(比如从none切到block)后它才会重算。这个现象和浏览器重绘时机有关,不一定每个版本都复现,但稳妥的做法有两个:
- 给气泡设置一个合理的
max-inline-size,限制宽度变化幅度; - 内容变化后强制触发一次重排,最简单的方式是给气泡加一个 class 再移除,或者用
requestAnimationFrame切一次display。
这不是锚点定位独有,传统 JS 定位组件里也会遇到,但在“零 JS”的语境里,没法在逻辑里主动重算,只能靠 CSS 约束来规避。
5. 致命坑三:浏览器支持断层,“零 JS”可能只是 Chrome 专属体验
5.1 我实测过的浏览器表现
锚点定位目前的浏览器支持情况,用一个词形容就是“不均衡”。我实际测试的感受是:
| 浏览器环境 | 实测表现 |
|---|---|
| Chrome / Edge 较新版本 | 主力属性稳定,anchor-name、position-anchor、anchor()、inset-area、position-try-fallbacks均可使用 |
| Safari 较新版本 | 支持锚点定位,但部分属性和拼写颗粒度和 Chrome 有差异,position-try-fallbacks的兼容性要单独验证 |
| Firefox 较新版本 | 开始支持较晚,我测试时部分简写属性仍不识别,建议以@supports兜底 |
| 国内常见 App 内 WebView / 旧 Chromium 内核 | 大概率完全不认,所有锚点相关声明等于无效 |
这里有一个比较微妙的地方:标题里写“零 JS”,但“零 JS”的前提是浏览器原生支持这套 CSS。如果你的产品用户大量使用旧 WebView,那这一章就是给你提个醒:零 JS 只是表象,背后需要有一个渐进增强策略。
5.2 渐进增强写法:@supports 兜底
我最常用的写法是把锚点定位作为“增强层”,而不是唯一实现。用@supports判断浏览器是否支持anchor-name:
.tooltip { position: absolute; left: 50%; bottom: calc(100% + 8px); translate: -50% 0; } @supports (anchor-name: --a) { .tooltip { position-anchor: --btn; inset-area: top; left: auto; bottom: auto; translate: none; } }这样在不支持锚点定位的浏览器里,气泡退化为“按钮正上方居中”的普通定位,虽然少了自动翻转,但至少功能可用。如果你的业务原本就有 JS 浮层组件,也可以保留 JS 方案作为默认,只在支持锚点定位的浏览器里切换过去。
我不建议用 UA 字符串判断浏览器,因为 WebView 的版本和 UA 经常对不上,@supports是更可靠的特性检测手段。
5.3 选型建议:什么时候值得上锚点定位
我的判断标准很简单:
- 如果只是 Tooltip、轻量 Popover,而且你的用户集中在较新的 Chrome / Edge,锚点定位可以直接替换掉大批 JS,连库都不用引。
- 如果是日期选择器、复杂下拉树、右键菜单这种需要大量边界计算、聚焦管理和键盘导航的组件,现阶段还是建议继续用 Floating UI 这类 JS 库,锚点定位可以作为增强选项在后台默默 work。
- 如果面对的是混合 App 里的 WebView 页面,必须提前做兼容测试,否则很可能会出现“Chrome 调试一切正常,打包进 App 一团糟”的线上事故。
我把这套技术用在内部管理系统的 Tooltip 上,体验非常顺;但在公网面向大众的产品里,浏览器环境不可控,我仍然会保留 JS 回退,不敢拿“零 JS”去赌。
6. 最后说点实操层面的经验
大半年用下来,我最推荐的组合是“popover + 锚点定位”。popover 自动解决顶层渲染、overflow 裁切和 z-index 问题,锚点定位解决“贴在哪里”的问题,两个加起来等于以前一整套浮层方案。而且 popover 可以用popovertarget直接从按钮唤起,连 :hover 都不用写,移动端体验也更友好。
一些小技巧也顺手分享出来:给气泡加role="tooltip"和aria-describedby,锚点定位不会自动建立可访问性关联;调试时多看一眼 DevTools 里锚点定位的计算结果,Chrome 在 Styles 面板里会直接展示anchor()的解析值,那是排查问题最快的入口;如果想在项目里小范围试验,先挑一两个 Tooltip 场景跑一版,观察自动翻转边界、滚动容器表现和 WebView 兼容性再决定是否铺开。
不过话说回来,锚点定位虽然香,我仍然建议你在项目里把它当成“增强”而不是“唯一解”。CSS 的优雅宣言和浏览器支持的骨感现实之间,还有很长一段路要走。先把兜底方案留好,再享受“零 JS”的爽感,这样踩坑的时候至少不会半夜被线上告警叫醒。