上篇文章有个遗留问题:dialog 把气泡顶进了 Top Layer,但气泡该「长」在触发按钮旁边。当时说的是 CSS 锚点定位(Anchor Positioning)能搞定,这篇来试试
先看老方案,要在按钮旁边挂一个气泡,纯 JS 的写法是这样的:
js
function placeTip(btn, tip) {
const r = btn.getBoundingClientRect();
tip.style.left = r.left + r.width / 2 - tip.offsetWidth / 2 + 'px';
tip.style.top = r.top - tip.offsetHeight - 8 + 'px';
}
placeTip(btn, tip);
window.addEventListener('resize', () => placeTip(btn, tip)); // 窗口变了重算
window.addEventListener('scroll', () => placeTip(btn, tip), true); // 滚动了重算
// 还得开个 IntersectionObserver 处理按钮滚出视口......
定位靠 getBoundingClientRect 手算,刷新靠两个监听器兜底,边界靠观察者收尾------一个气泡,三套机制。
CSS 锚点定位的思路完全相反:让定位元素声明认定哪个锚点,剩下的事浏览器自己算,滚动、缩放全程自动跟随,一行 JS 都不用写。
1. 锁定锚点
核心属性是以下三个:
css
.btn { anchor-name: --btn; } /* 锚:给按钮起个名字 */
.tip {
position: absolute;
position-anchor: --btn; /* 定位元素:我认这个锚 */
bottom: anchor(top); /* 我的底边 = 锚的顶边 */
justify-self: anchor-center; /* 水平居中于锚 */
}
anchor(top) 返回锚元素顶边的位置,只能用在 inset 系属性里。justify-self: anchor-center 是新出的专用居中值------以前居中一个 absolute 元素要 left: 50% + translateX(-50%) 两行黑魔法,现在一个值完事。
自己先猜一下居中精度能到多少,再看测试:
yaml
=== T1 基础吸附:bottom: anchor(top) + justify-self: anchor-center ===
btn1 rect: {'x': 24, 'y': 806, 'right': 130, 'bottom': 840}
tip1 rect: {'x': 17, 'y': 780, 'right': 137, 'bottom': 806}
tip1底边贴btn1顶边? True (tip1.bottom=806, btn1.y=806)
水平居中于锚点? True (中轴偏差 0.0px)
贴边严丝合缝,居中偏差 0.0px 。这就是锚点定位的精髓:只需要声明关系,几何交给浏览器。
2. anchor() 用错地方,不报错,静默失效
上面说了 anchor() 只能用在 inset 系属性里。那要是写错了呢?比如顺手写进 transform:
css
.tip3a {
position: absolute;
transform: translateX(anchor(--btn3 center)); /* 故意写错位置 */
}
再看另一个更阴的:名字打错一个字。
css
.tip3b {
position: absolute;
position-anchor: --nonexistent; /* 没有任何元素叫这个名字 */
bottom: anchor(top);
}
测试一下:
yaml
=== T3 雷区:anchor 写进 transform / 引错名字 ===
tip3a transform 实际值: none
transform 整条失效(=none)? True
tip3b rect: {'x': 24, 'y': 1075, 'right': 236.5, 'bottom': 1101}
tip3b 是否吸附到 btn3 上方? False (btn3.y - tip3b.bottom = -60px)
页面 console 错误数: 0
- anchor() 写进 transform,整条 transform 声明失效 ,计算值直接变
none; - position-anchor 引了不存在的名字,吸附没发生,元素停在原地(偏了 60px);
console 错误数 0,这可要了命了。没有错误,没有警告,CSS 就当无事发生。生产里气泡莫名待在原地,先查 anchor 名字拼没拼对,这个不会报错
3. popover「自带锚点」?
有一篇关于popover的文章,规范里有个设计:用 popovertarget 调起的 popover,自带一个隐式锚点,就是触发它的那个按钮 。按文档的说法,popover 里直接写 position-area: bottom 就能吸附到按钮下方,一行认锚代码都不用写。
测试一下
css
#p2 { position-area: bottom; margin: 0; } /* A:按文档原样 */
#p2b { position-area: bottom; margin: 0; inset: auto; } /* B:怀疑 UA 默认 inset:0 捣乱 */
#p2c { position-area: bottom; margin: 0; inset: auto;
position-anchor: implicit; } /* C:显式认隐式锚 */
结果就是三组全灭:
arduino
=== T2 implicit anchor:popover 不写锚点名,position-area: bottom ===
p2 rect: {'x': 302.3, 'y': 0, 'right': 497.7, 'bottom': 32}
气泡顶边在按钮下方? False (p2.y=0, btn2.bottom=967)
=== T2b 对照组:同样不写锚点名,但加一行 inset: auto ===
p2b rect: {'x': 320.4, 'y': 0, 'right': 479.6, 'bottom': 32}
=== T2c C组:inset:auto + position-anchor: implicit ===
p2c rect: {'x': 295.1, 'y': 0, 'right': 504.9, 'bottom': 32}
CSS.supports position-anchor:implicit = False
positionAnchor 计算值: p2=auto, p2b=auto, p2c=auto
三组气泡全部无视按钮,停在视口顶部水平居中 的默认位。B 组排除了 UA 样式捣乱,C 组暴露了真相:CSS.supports('position-anchor: implicit') 返回 False ------本机 Chromium(143.0.7499.4)压根没实现 implicit 这个关键字,声明整条作废,computed 值掉回 auto。
也就是说:popover 自带隐式锚目前是规范愿景,Chromium 还没兑现。MDN 上的写法没问题,是浏览器的实现进度没跟上。
那想让 popover 吸到按钮旁边怎么办?
css
.btn2d { anchor-name: --btn2d; } /* 按钮起名 */
#p2d {
position-area: bottom;
margin: 0;
inset: auto; /* 记得松开 UA 的默认 inset */
position-anchor: --btn2d; /* 显式认锚 */
}
yaml
=== T2d D组:放弃白嫖,显式锚点(anchor-name + position-anchor: --btn2d)===
btn2d rect: {'x': 687.9, 'y': 933, 'right': 755.2, 'bottom': 967}
p2d rect: {'x': 602.9, 'y': 967, 'right': 840.2, 'bottom': 999}
气泡顶边在按钮下方? True (p2d.y=967, btn2d.bottom=967)
水平对齐按钮中轴? True (中轴偏差 0.0px)
立刻吸附:967 = 967,中轴偏差 0.0px。popover 照样吃锚点红利,只是今天得自己起名 。顺带记住那个 inset: auto------popover 的 UA 默认样式带着 inset: 0 + margin: auto(就是它把气泡摁在视口正中),不松开这一手,position-area 没法干活。
4. 这些 JS 可以删了
显式认锚麻烦是麻烦了点,但却很值。
第一点就是溢出自动翻转。 按钮贴着视口右缘,气泡想往右展开必然溢出。以前要监听 resize 手动 flip,现在:
css
#tip4 {
position: fixed;
position-anchor: --btn4;
left: anchor(right); /* 想贴按钮右侧展开 */
top: anchor(top);
position-try-fallbacks: flip-inline; /* 放不下就左右镜像 */
}
arduino
=== T4 视口右缘:position-try-fallbacks: flip-inline ===
btn4 rect: {'x': 688, 'y': 260, 'right': 794, 'bottom': 294}
tip4 rect: {'x': 542, 'y': 260, 'right': 688, 'bottom': 304}
tip4 完全在视口内? True (tip4.right=688)
翻到按钮左侧? True (tip4.right=688 vs btn4.x=688)
右缘放不下,自动翻到按钮左侧,贴齐误差 0。resize 监听器可以删了。
第二点是方向+对齐一行写完。 position-area: bottom span-right 的意思:锚点下方那一行,从锚的左缘向右跨两格。
sql
=== T5 position-area: bottom span-right ===
tip5 在按钮下方? True
tip5 左缘对齐按钮左缘? True
比手写 top: anchor(bottom) 再算水平偏移直观得多,想把气泡甩到右下角,改一个关键字的事。
第三点借锚点的宽度。 菜单想和触发按钮等宽?anchor-size(width) 直接把锚的宽度借过来:
scss
=== T7 anchor-size(width):宽度跟锚点一样 ===
btn7 宽: 161.7, tip7 宽: 161.7
tip7 宽度 = btn7 宽度? True
还有一个坑,anchor-size 返回的是锚的 border-box 尺寸,自己的 width 默认是 content-box------忘了 box-sizing: border-box 就会差出 padding 那 16px,测试的时候踩过,可以直接避免。
5. 属性支持 ≠ 行为落地
滚动隐藏这块,规范给了 position-visibility: anchors-visible:锚点滚出可视区,浮层自动消失------理论上又能替掉一个 IntersectionObserver。CSS.supports 检查也是 True。把锚点滚出祖先滚动容器测了一遍,又滚出浏览器视口测了一遍:
yaml
=== T6a anchors-visible:锚点滚出祖先滚动容器(scroller)===
btn6 相对 scroller 可视区: top=-93, bottom=-59
锚点滚出 scroller 后 tip6 display: block, 隐藏? False
=== T6b anchors-visible:锚点滚出浏览器视口(页面滚动)===
btn6 视口 y: -60 (负数=已滚出视口顶部)
滚出视口后 tip6 display: block, rect: {'x': 24, 'y': -26, 'right': 222, 'bottom': 0}
锚点滚出视口后浮层隐藏? False
属性解析通过,两种场景都不干活。这就是第二个坑的教训:CSS.supports 返回 True 只代表语法被认识,不代表行为已实现。滚动隐藏这个需求,今天老老实实 IntersectionObserver,别赌。
6. 支持度与知识图谱
截至发稿(2026-09,本机实测 Chromium 143):锚点定位核心能力 Chrome 125+ / Safari 26+ / Firefox 147+,Baseline 2026 年 1 月起全绿;但 position-anchor: implicit 关键字与 anchors-visible 的实际行为,本机均未落地------Baseline 达标 ≠ 全量特性可用,用之前逐项验。
arduino
CSS 锚点定位实测知识图谱
├─ 认锚 ── anchor-name(锚起名)+ position-anchor: --name(✅ 稳)
│ position-anchor: implicit(❌ 规范有,Chromium 143 未实现)
├─ 定位 ── anchor(top/right/...) → 只写进 inset 系(写错 = 整条声明静默失效)
│ justify-self: anchor-center → 专用居中(✅ 稳)
├─ 尺寸 ── anchor-size(width) → 借锚的 border-box 宽,自己记得加 box-sizing(✅ 稳)
├─ 方向 ── position-area: bottom span-right → 一行 = 方向 + 对齐(✅ 稳)
├─ 溢出 ── position-try-fallbacks: flip-inline → 零 JS 自动翻转(✅ 稳)
└─ 显隐 ── position-visibility: anchors-visible(❌ 语法认、行为未落地)
需要显示找锚点,anchor() 只进 inset 系,Baseline 不等于全落地
哪些可以用?
- 可放心使用 :显式
anchor-name+position-anchor、position-area、justify-self: anchor-center、position-try-fallbacks、anchor-size()(记得加box-sizing)。 - 千万别在生产用 :
position-anchor: implicit(隐式锚点)、position-visibility: anchors-visible(滚动自动隐藏)。 - 使用
popover+ 锚点定位时,务必加上inset: auto,否则 UA 默认样式会把气泡钉在视口正中。
参考*
- CSS Anchor Positioning Module Level 1(W3C,2026-03-27)
- Chrome Developers: CSS 锚点定位 API
- web.dev: Learn CSS Anchor Positioning
- MDN: Using CSS anchor positioning
- web.dev blog: New to the web platform in January(Firefox 147 → Baseline 2026-01)
Demo 获取:欢迎gzh程序员蜡笔熊后台回复「锚点」取测试demo
气泡的位置问题解决了,动效还开着 scroll 监听呢?CSS 滚动驱动动画把 timeline 都收编了,animation-timeline: view() 一行,滚到哪播到哪?下一篇可以接着探讨