做下拉菜单和 Tooltip 的时候,最烦的从来不是把弹层画出来,而是让它待在正确的地方:按钮在屏幕右边,菜单就得往左翻;按钮贴着视口底边,提示就得冒到上面去;页面一滚,弹层得跟着按钮走,不能脱钩。这些事以前只能交给 JS,于是项目里多了 Popper.js,多了十几个 getBoundingClientRect(),多了滚动时的一堆重排。
CSS 锚点定位(Anchor Positioning)就是为了这件事来的。它让一个元素可以用另一个元素的位置来定位自己,而且是纯声明式的——浏览器算,你不管。这篇文章不铺概念,直接做三个能用的组件,中间把几个抄老教程一定会踩到的地方说清楚。
一、三件套:谁来当锚点、我去找谁、我待在哪个方向
整件事只有三个属性参与,各管一件事:
anchor-name:写在锚点元素上,给这个元素起一个名字,必须是以--开头的标识符。position-anchor:写在要定位的元素上,声明它默认跟随哪个锚点。position-area:声明自己待在锚点的哪一侧。
锚点元素和被定位元素需要处在同一个包含块里,最省事的做法是给它们套一个 position: relative 的父元素。这一点跟绝对定位的规则是一致的,不用另外记。
<div class="wrap">
<button class="btn">点我</button>
<div class="panel">我是弹层</div>
</div>
.wrap { position: relative; }
.btn { anchor-name: --my-btn; }
.panel {
position: absolute;
position-anchor: --my-btn;
position-area: bottom;
}
就这么几行,.panel 会稳稳贴在按钮下方,按钮怎么移动、页面怎么滚、窗口怎么缩放,它都跟着走。不需要事件监听,也不需要 requestAnimationFrame 去追帧。
二、案例一:按钮上方的运费提示
先做一个最朴素的 Tooltip。HTML 结构里多给了一个 aria-describedby,因为提示文案本质上是描述,读屏用户应该能关联到触发按钮:
<p class="price-row">
合计 <strong>¥128.00</strong>
<button class="tip-btn" type="button" aria-describedby="tip-shipping">运费说明</button>
<span id="tip-shipping" class="tip" role="tooltip">满 99 元包邮,偏远地区除外。</span>
</p>
.price-row {
position: relative;
}
.tip-btn {
anchor-name: --ship-tip;
border: 0;
background: none;
text-decoration: underline dotted;
cursor: help;
}
.tip {
position: absolute;
position-anchor: --ship-tip;
position-area: top;
margin: 0 0 8px;
padding: 6px 10px;
font-size: 13px;
line-height: 1.5;
white-space: nowrap;
background: #1f2328;
color: #fff;
border-radius: 6px;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s;
pointer-events: none;
}
.tip-btn:hover + .tip,
.tip-btn:focus-visible + .tip {
opacity: 1;
visibility: visible;
}
这里有三个点值得单独拎出来说。
position-area: top 之后,原本的 top / left 就别再写了。 position-area 会把锚点周围切出一圈格子,元素被放进指定的那个格子,同时这些格子会成为这个元素的包含块。你在它上面再写 top: 0,结果是在那个小格子里再挪一次,位置会变得莫名其妙。要微调就用 margin。
margin: 0 0 8px 是给提示和按钮之间留条缝。 这个 margin 不是在页面流里生效的,而是在 position-area 划出来的格子里把元素往外推。写 translate 也能达到类似效果,但 margin 的语义更准确——它就是间距。
visibility 而不是 display。 切换 display: none 会让元素脱离定位流,再切回来时定位会重新计算,视觉上会闪。用 visibility + opacity 过渡最平滑,代价只是元素一直存在(对这个尺寸的提示来说无所谓)。
如果你要加那个小三角箭头,用伪元素在提示框内部画就行,不需要什么锚点技巧:
.tip::after {
content: "";
position: absolute;
inset: auto auto -4px 50%;
width: 8px;
height: 8px;
translate: -50% 0;
rotate: 45deg;
background: inherit;
}
background: inherit 让箭头颜色自动跟着提示框走,以后改主题色不用改两处。
三、案例二:右边缘对齐的下拉菜单
position-area 给的是”大致方位”,但真实菜单经常要求更精确的对齐——比如菜单的右边缘必须和按钮的右边缘齐平。position-area 的格子默认跟锚点等宽,按钮窄、菜单宽的时候就会错位。这种场景要请出 anchor() 函数。
<nav class="toolbar">
<button class="more-btn" type="button">更多操作</button>
<ul class="menu">
<li><a href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" >导出 CSV</a></li>
<li><a href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" >批量删除</a></li>
<li><a href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" >调整排序</a></li>
</ul>
</nav>
.toolbar {
position: relative;
}
.more-btn {
anchor-name: --more-btn;
}
.menu {
position: absolute;
top: anchor(--more-btn bottom);
right: anchor(--more-btn right);
margin-top: 6px;
min-width: anchor-size(--more-btn width);
padding: 4px 0;
margin-inline: 0;
list-style: none;
background: #fff;
border: 1px solid #d8dee4;
border-radius: 8px;
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
}
anchor(--more-btn bottom) 读作”按钮的下边缘”,把它赋给 top,菜单就从按钮底部开始排。anchor(--more-btn right) 是按钮的右边缘,赋给 right,两条右边缘就对齐了。anchor() 返回的是一个长度值,能用在 top / right / bottom / left / inset-* 这些地方。
注意这里的写法不需要 position-anchor。anchor() 函数里已经把名字写死了,它自己会去找对应的锚点。只有当你想用简写的 position-area 时才需要 position-anchor 告诉浏览器”默认跟随谁”。两种方式的用途不同,不要混着写,容易让人读代码时困惑。
还有一个顺手功能:anchor-size(--more-btn width) 返回锚点的宽度,这里拿来设 min-width,菜单至少不会比按钮还窄。同理可以取 height,做那些需要和按钮等高的场景。
四、边界翻转:别让菜单跑到屏幕外
上面这套在按钮位于视口顶部时没问题,但按钮贴着屏幕底边的时候,菜单还是会往下张开,然后被截掉一半。传统方案是 JS 算一下剩余空间,然后加个 --placement="top" 的 class;CSS 这边一个属性搞定:
.menu {
position-try-fallbacks: --open-up;
}
@position-try --open-up {
top: auto;
bottom: anchor(--more-btn top);
margin-top: 0;
margin-bottom: 6px;
}
@position-try 是一个专门用来写”备选定位方案”的规则块,里面写的内容和普通规则一样,但它不会直接应用,只有在主方案放不下时才会被拿来尝试。position-try-fallbacks 列出候选清单,按顺序试,第一个能放下的就采用。
上面的 --open-up 做了一件很简单的事:把 top 归零,改成用 bottom 锚定到按钮上边缘,间距也反过来记在 margin-bottom 上。浏览器检测到主方案(向下展开)超出了包含块,就会自动切到这一套。
除了自定义方案,CSS 还内置了四个开关,日常够用:
flip-block:块轴方向翻转(上下互换)flip-inline:行内轴方向翻转(左右互换)flip-block flip-inline:两个方向都允许翻same-area/most-width/most-height这类尺寸择优策略
所以更省事的写法其实是:
.menu {
position-try-fallbacks: flip-block, flip-inline;
}
让浏览器自己判断往哪边翻。@position-try 留给那些内置策略表达不了的定制需求,比如”翻上去的时候顺便换个圆角方向”。
这里有个需要提前知道的细节:翻转判断是相对于元素的包含块做的,不是相对于视口。如果 .toolbar 本身高度只有 40px,菜单刚往下伸一点点就”超出包含块”了,翻转会比预期更早触发,看起来像抽风。遇到这种情况,要么把下拉层挪进 top layer(就是下面说的 Popover),要么给它改用 position: fixed 定位,让包含块变成视口。
五、Popover 是个好搭档,但有两处得收拾
把弹层做成 popover 通常是更好的选择:它天然在顶层,不会被父元素的 overflow: hidden 裁掉,也不会被别的元素盖住。
<button popovertarget="tip" class="tip-btn">帮助</button>
<div id="tip" popover class="tip">这里是说明</div>
好消息是,Popover 元素会把自己的触发按钮当作隐式锚点,连 anchor-name 都可以省掉,直接写 position-area 就行。但这一步很容易翻车——Popover 的浏览器默认样式里有这么几条:
/* 浏览器 UA 样式,粗略示意 */
[popover] {
position: fixed;
inset: 0;
margin: auto;
width: fit-content;
height: fit-content;
}
inset: 0 加上 margin: auto 的组合,效果是”在视口正中显示”。你写了 position-area 之后,如果没把 inset 和 margin 清掉,两者会打架,结果经常是弹层依旧稳坐屏幕中央,让人以为锚点定位压根没生效。
.tip {
position-anchor: --ship-tip;
position-area: top;
inset: auto; /* 关键:清掉 UA 的 inset: 0 */
margin: 0 0 8px; /* 关键:清掉 UA 的 margin: auto */
}
补上这两行就正常了。另外,虽然隐式锚点能用,我还是建议显式写上 anchor-name 和 position-anchor——一是代码可读,二是不同浏览器对隐式锚点的实现细节略有差异,显式声明更稳。
六、锚点滚走了,弹层还留在原地?
这个问题第一次遇到会有点懵:页面往下滚,按钮已经滚出视口,但跟着它的弹层还飘在那儿。锚点定位只负责算位置,不负责判断可见性。要处理的话:
.menu {
position-visibility: anchors-visible;
}
这行的意思是:锚点不在视口里可见时,把自己也藏起来。对于跟随滚动的提示、下拉层来说基本是必选项。它和 overflow 的裁剪判断是分开的,不用担心会被 overflow: hidden 的父元素误伤——这一点比传统的 JS 方案省心多了。
七、五个绕不开的坑
1. anchor-name 是 CSS 属性,不是 HTML 属性
早期草案里有个 HTML 的 anchor 属性,抄到那些文章里的写法会完全不生效,而且控制台不会给任何提示。记住:锚点名字只能通过 CSS 的 anchor-name 声明。
2. 好几个属性改过名,老代码直接失效
这个坑最耗时,因为不生效的时候浏览器是静默的:
inset-area→ 现在叫position-areaposition-try/position-try-options→ 现在叫position-try-fallbacks
看到教程里写 inset-area,基本可以直接判断是去年的内容了。迁移时全局搜一遍这几个词,比一个个调位置快。
3. 同名锚点会有歧义
一个页面上有十个卡片,每张卡片都有自己的”更多”按钮,如果都给它们写 anchor-name: --more;,谁是谁就说不清了。稳妥做法是用 anchor-scope 把名字限制在子树内:
.card {
anchor-scope: --more;
}
.card .more-btn {
anchor-name: --more;
}
这样每张卡片内部的 --more 是独立的,互不干扰。
4. 锚点不能是 display: none
被隐藏的锚点不算有效锚点,引用了它等于没引用。另外跨 iframe、跨 shadow root 的锚点查找也不在标准支持范围内,做微前端或者组件库时要注意这层边界。
5. 别在锚点元素上做 transform 动画
锚点的位置是按布局盒计算的,transform 不改变布局盒。按钮滑入的同时弹层跟不跟得上,取决于浏览器对”锚点位置”的定义是否涵盖变换后的视觉位置——这块各引擎的实际表现不一致。要动就动外面那层容器,或者干脆先让动画跑完再显示弹层。
八、降级:先能点,再好看
锚点定位的支持度这两年涨得很快,Chromium 内核 125 之后已经默认开启,Safari 在较新版本也跟上了,Firefox 相对保守一些。上线之前建议直接用 @supports 探一下,写一套兜底布局:
.menu {
position: absolute;
inset-block-start: 100%;
inset-inline-end: 0;
}
@supports (anchor-name: --x) {
.menu {
inset: auto;
inset-block-start: anchor(--more-btn bottom);
inset-inline-end: anchor(--more-btn right);
}
}
不支持的环境里,菜单依然在按钮下方、右侧对齐——按钮本来就在容器的右上角,视觉上差不了多少。支持的环境里,无论按钮挪到哪儿,菜单都能准确咬住。这种”先能用再优化”的顺序比反过来安全得多。
如果某天交互需求变复杂,想在菜单里再套一个跟着鼠标的子菜单,那还是建议退回 JS。锚点定位解决的是”元素 A 相对元素 B 的位置”,不解决”跟着鼠标跑”这种需求。
九、什么时候还是继续用 JS
说了这么多好处,也得把边界划清楚,不然容易在错误的地方硬上:
- 需要跟随光标(右键菜单、画布上的浮动工具条)。锚点定位的锚点是一个元素,不是坐标点。
- 弹层尺寸依赖内容测量。比如必须等菜单渲染完才知道它多宽,然后反推一个偏移量。这类”先量后摆”的循环 CSS 做不了。
- 需要和滚动做反向补偿。比如滚动时让弹层带一点阻尼延迟。
- 对旧浏览器零容忍的项目。如果客户群里还有大量老旧设备,用现成的 JS 库更省心,反正它已经处理好了兼容层。
除此之外的大部分场景——下拉菜单、Tooltip、气泡卡片、跟随输入的校验提示——都可以直接把 JS 那层砍掉。砍完之后你会发现,滚动时那个”弹层轻微抖动一下才跟上”的毛病,也跟着一起消失了。

