一个下拉面板要贴在按钮下面,这个需求简单得听起来都不值得写一篇文章。但真要做得在浏览器里站得住,麻烦事不少。
用 position: absolute 加 top: 100% 贴父元素,页面本身还能滚的时候没问题;如果这个面板在一个 overflow: hidden 的卡片里,直接就被裁掉了。换成把面板挂到 body 上用 position: fixed,躲过了裁剪,但接下来要拿按钮的坐标——这一步只能靠 JS,还得监听滚动和尺寸变化。然后是最烦的那个:如果按钮已经在视口底部,面板往下弹看不见,得往上翻。翻的逻辑又是 JS。
这套东西过去十几年的固定解法,是引入一个定位库(Popper、Floating UI 之类),它们把「贴谁、往哪贴、空间不够怎么办」这件事做得非常成熟。代价是多一份依赖,还有一份得跟着上游版本走的维护成本。
CSS 锚点定位(CSS Anchor Positioning)想做的就是把这件事收回到 CSS 里。到 2025 年,Chrome 和 Edge 已经支持得很完整,Safari 的新版本也跟上了,Firefox 还在推进中。这篇文章把它的几种核心用法讲清楚,最后说几个实际项目里会撞到的坑。
一、先想清楚「锚点」是什么
过去做绝对定位的时候,参照物是「最近的定位祖先」。这意味着被定位的元素必须是参照物的后代,而且参照物的位置关系是它在 HTML 树里的位置。
锚点定位把这个约束解开了。参照物是一个「被命名的元素」,和定位元素之间不需要任何树结构上的关系。一个面板挂在 body 上、贴在某个深处的按钮旁边,这在锚点定位里是自然的。
规则只有两步。给锚点起个名字:
.trigger {
anchor-name: --my-anchor;
}
让目标元素指向这个名字:
.panel {
position: fixed;
position-anchor: --my-anchor;
top: anchor(--my-anchor bottom);
left: anchor(--my-anchor left);
}
这里有几个地方值得停下来看。
名字是「虚线标识符」(dashed ident)。必须以两条短横线开头,和自定义属性的写法一样。为什么要强制这个前缀?因为锚点名走的是一套独立的命名空间,用 -- 开头能一眼把它和元素 ID、类名区分开,也避免了和未来可能出现的其他标识符冲突。
position-anchor 和直接在 anchor() 里写名字是两种写法。position-anchor 相当于给这个元素设了个「默认锚点」,之后所有 anchor() 都可以省略参数。如果只有一两个方向需要引用锚点,两种写法差别不大;但如果要写 top、left、width、height 好几条,省略参数会让代码干净不少。
position: fixed 还是 absolute?这个要结合场景选。挂在 body 上、需要不被任何祖先裁剪的时候用 fixed;如果面板就在触发按钮旁边、结构上没太多限制,用 absolute 更轻。
二、anchor() 函数:一个方向一个方向地摆
anchor() 返回值是一个长度,可以用在 top、bottom、left、right、width、height、inset-* 这些属性上。它有两个参数:锚点名(可以省略,用 position-anchor 里的默认值)和位置关键字。
位置关键字里最常见的是 top、bottom、left、right、center,以及各自的百分比位置,比如 anchor(50%)。另外还有两个组合关键字 start 和 end,它们会跟随文本方向自动切换——在从左到右的语言里 start 是左,在从右到左的语言里就是右。做多语言网站的时候优先用这两个,免得账号名是阿拉伯语的时候整个面板跑到屏幕外面去。
回到最简单的下拉面板。按钮在左边,面板要和按钮左边缘对齐、紧贴按钮底部往下弹:
.trigger {
anchor-name: --dropdown-trigger;
}
.panel {
position: fixed;
position-anchor: --dropdown-trigger;
top: anchor(bottom); /* 顶部贴着锚点的底部 */
left: anchor(left); /* 左边和锚点左边对齐 */
margin-block-start: 6px; /* 留一点间距 */
min-inline-size: anchor-size(width);
}
anchor(bottom) 会被求值成锚点底边距离视口顶部的距离。所以 top: anchor(bottom) 的意思就是「这个面板的顶部,放在锚点的底边上」。很直白。
最后那行 min-inline-size: anchor-size(width) 用的是另一个函数 anchor-size()。它拿的是锚点的尺寸——面板至少要和按钮一样宽,不然按钮上写着「下载报表」,面板里弹出来一个窄窄的框,视觉上很别扭。anchor-size() 常见用法还有 anchor-size(block)(拿高度)和 anchor-size(100%)(拿某个维度上的百分比)。
居中:anchor-center
很多场景里我们希望面板相对锚点水平居中,但它宽度比按钮大。硬算的话要写 left: calc(anchor(center) - 100px) 这种,得自己知道面板宽度,一改宽度就要跟着改。
anchor-center 就是为这个准备的:
.panel {
position: fixed;
position-anchor: --dropdown-trigger;
top: anchor(bottom);
justify-self: anchor-center; /* 在锚点的水平中线上居中 */
}
或者用在 inset-inline 上:inset-inline: anchor-center。它会按锚点的水平中线把元素摆好,不管目标是宽是窄。
三、position-area:不用写四个方向,一句话搞定
刚才那两行 top 和 left 其实可以合成一行。
.panel {
position: fixed;
position-anchor: --dropdown-trigger;
position-area: bottom span-right;
margin-block-start: 6px;
}
position-area 的语法是用两个词描述「放在锚点的哪个方位」。它想象一个以锚点为中心的 3×3 网格——上、中、下三行,左、中、右三列。两个词从不同的轴里各取一个。
看看几个常见组合:
bottom:放在锚点正下方,水平居中。top:放在锚点正上方,水平居中。bottom span-right:放在下方,左边和锚点左边缘对齐,向右展开。bottom span-left:放在下方,右边和锚点右边缘对齐,向左展开。left:放在锚点左侧。block-end:按书写方向在「块方向末尾」——英文是下,竖排中文可能是右。
第一次看到 span-right 这个词容易懵,其实它说的不是「面板往右展开」这么运动学的描述,而是「面板占住锚点右侧那格」。因为它只是占据位置,实际渲染时面板可能比那格宽,就会向右溢出。这正是我们要的效果——按钮在左边、面板贴左边向右长出。
另外,position-area 会自动处理居中和尺寸约束。比如写 position-area: bottom,如果面板比锚点窄,它会水平居中在锚点下方;如果比锚点宽……它也会尝试在可用空间里放置,具体行为参考规范里的「默认尺寸调整」。这一层自动化的心智模型和手写 anchor() 不同,用之前最好在浏览器里试一下预期效果,别凭空想象。
四、@position-try:空间不够时的自动翻转
这个是我认为锚点定位里最有价值的一块。它把「按钮在页面底部,面板应该往上弹」这件事,从 JS 逻辑变成了 CSS 声明。
先定义几个「备选位置」:
@position-try --flip-up {
top: auto;
bottom: anchor(top);
margin-block-start: 0;
margin-block-end: 6px;
}
@position-try --shift-left {
left: auto;
right: anchor(right);
}
第一条说的是:「如果被选中,就改成在锚点上方」。注意它必须把 top 显式设成 auto,因为默认样式里已经定了 top,不重置的话两条规则会打架,实际效果会以优先级更高的那条为准。
然后把这个备选位置挂上去:
.panel {
position: fixed;
position-anchor: --dropdown-trigger;
position-area: bottom span-right;
position-try-fallbacks: --flip-up, --shift-left;
}
浏览器放置面板时会按顺序尝试:先试 position-area 指定的位置;如果面板会超出视口边界(默认是视口,也可以改),就试第一个备选位置;还不行就试第二个;都试完还不行,就退回默认位置。
顺序很重要。--flip-up 在前,因为「往上翻」通常比「往左挪」更符合直觉。如果反过来,用户会遇到「按钮在底部,面板往左偏了一大截」这种怪异体验。
用内置关键字省点事
手写翻转位置挺啰嗦,所以规范里其实提供了几个内置的备选位置关键字:
.panel {
position-area: bottom;
position-try-fallbacks: flip-block, flip-inline;
}
flip-block:沿块轴翻转。原来在下方就翻到上方,反之亦然。flip-inline:沿内联轴翻转。原来在右就翻到左。flip-start:一个组合关键字,等价于「先flip-block再flip-inline」这一整套。
实际项目里我一般用 flip-block 加一个手写的 --shift-inline,因为纯翻转在水平方向上不好使——面板比按钮宽很多的时候,翻转之后照样可能超出屏幕,这时候需要的是「平移」,而不是「翻到另一边」。
关于视口的边界
默认情况下,position-try-fallbacks 判断的是「会不会超出视口」。如果面板在某个滚动容器里,超出视口没事但超出容器就不行,那还需要配合 position-visibility 或者让锚点定位感知到边界。这块规范还在演进,不同浏览器的行为在边角情况上未必完全一致。稳妥起见,容器内的面板尽量让容器本身留出足够空间。
五、完整案例:跟按钮联动、自动翻转的下拉面板
把上面这些拼一下。HTML 结构:
<button class="menu-trigger" popovertarget="menu">操作</button>
<div id="menu" popover class="menu-panel">
<button>重命名</button>
<button>复制链接</button>
<button>移动到</button>
</div>
关键 CSS:
.menu-trigger {
anchor-name: --menu-anchor;
}
.menu-panel {
position: fixed;
position-anchor: --menu-anchor;
position-area: bottom span-right;
margin-block-start: 6px;
min-inline-size: anchor-size(width);
position-try-fallbacks: flip-block;
inset-block-start: 6px auto;
/* popover 的默认样式会干扰,见下一节 */
margin: 0;
}
@position-try --flip-up {
top: auto;
bottom: anchor(top);
}
那个 inset-block-start: 6px auto 是把「默认样式可能残留的 top 值」强制重置一下,避免和 position-area 算出来的位置打架。实际写的时候这个没必要,但如果你是从老 CSS 迁移过来的,老规则里遗留的 top 很可能让浏览器忽略 position-area。这类「看起来没生效」的症状,九成是因为有个隐式的 inset 在捣乱。
再看一下 @position-try 那个块的名字。它必须和 CSS 里的 --flip-up 完全一致,写错的话浏览器会静默忽略——不会报错,不会警告,只是这个备选位置默默不生效。这是这类新特性比较折磨人的地方,调试的时候优先检查这条。
六、案例二:跟随光标的 tooltip,纯 CSS 实现
锚点定位还有一个挺妙的用法:让 tooltip 跟随光标位置。过去这个是 JS 的专场,现在只要在 mousemove 或者 pointermove 里更新两个 CSS 变量就够了。
<div class="chart-area">
<div class="cursor-tip">--</div>
</div>
.chart-area {
position: relative;
anchor-name: --chart;
}
.cursor-tip {
position: absolute;
position-anchor: --chart;
top: anchor(top);
left: anchor(left);
translate: var(--tip-x, 0) var(--tip-y, 0);
translate: calc(-50% + var(--tip-x, 0)) calc(-100% - 10px);
transition: translate 60ms linear;
}
方向是:把 tooltip 放在锚点(图表区域)的左上角,然后用两个 CSS 变量 --tip-x 和 --tip-y 把它推到光标所在的那个点。JS 那边只需要:
const area = document.querySelector('.chart-area');
area.addEventListener('pointermove', (e) => {
const rect = area.getBoundingClientRect();
area.style.setProperty('--tip-x', `${e.clientX - rect.left}px`);
area.style.setProperty('--tip-y', `${e.clientY - rect.top}px`);
});
逻辑很轻——不需要读 tooltip 自己的尺寸、不需要处理滚动、不需要测算边界。位置计算全在 CSS 里,JS 只负责把「光标在哪儿」这一个事实传进去。
这里有个细节:translate 那两行其实只有第二行生效,第一行是为了演示「用 CSS 变量做偏移」的思想。真正写的时候只留一行就行。另外 translate 和 transform 是两个独立属性,前者不影响 position-anchor 的定位计算,后者可能会。用 translate 更安全。
七、和 popover 配合的几个实际坑
锚点定位加上 popover 是很好的组合——popover 负责显示隐藏、点击外部关闭、顶层渲染这些行为,锚点定位负责位置。但两者凑在一起的时候有些小摩擦。
1. popover 的浏览器默认样式会覆盖你的定位
浏览器给 popover 元素加了一条 UA 样式:inset: 0,用来把它居中。这条样式会覆盖掉你的 top 或者 position-area——不是完全覆盖,而是在没有明确指定某个方向时给个默认值。解决方法是显式声明相关的 inset 属性。最省事的写法是:
[popover] {
margin: 0;
inset: auto;
}
把默认的 inset: 0 重置掉,之后你自己写的 position-area 或者 anchor() 就能正常生效。这几行放在全局比较合适。
2. popover 在顶层,anchor 也在顶层,这没问题
一个好消息是:popover、dialog 打开后都在顶层渲染(top layer),而锚点定位完全可以跨层引用。所以一个在顶层里的面板,引用一个在常规文档流里的按钮作为锚点,是支持的,而且不受 overflow: hidden、z-index、transform 上下文的影响。这正好解决了 position: absolute 时代最麻烦的那些裁剪问题。
3. 锚点消失时的行为
如果锚点元素被从 DOM 里移除,或者被 display: none,面板会怎么样?默认行为是面板退回它没有锚点时的位置——通常是视口的某个角落,很难看。position-visibility 就是为了处理这个:
.menu-panel {
position-visibility: anchors-visible;
}
它的意思是:只有当锚点可见的时候,面板才可见。锚点被隐藏了,面板跟着一起隐藏。这个值有几种可选:always(默认)、anchors-visible、no-overflow。日常用 anchors-visible 就够了。
八、几个调试上的提醒
锚点名字对不上会静默失败。anchor-name: --foo 和 position-anchor: --foo-bar,差一个字符的事。这两个前缀都是 --,看过去很像一大段一模一样的符号,特别容易漏看。用开发者工具的 Styles 面板按名字搜一下,比肉眼快。
多个元素同名的话,最后一个生效。anchor-name 是全局的(严格说是作用域内的),如果页面上有两个元素都叫 --menu-anchor,谁在文档里靠后就以谁为准。列表页里面每一项都有个同名的下拉锚点,这是非常容易翻车的场景。解决办法是给每个实例生成唯一的锚点名——写一段 JS 在初始化时分配,或者用 CSS 变量在下挂时拼名字。虽然不够优雅,但目前没有更好的方案。
滚动的时候会重新计算。这是好事情,滚动位置变了面板会跟着锚点走。但如果锚点在一个滚动容器里、而面板挂在 body 上的 fixed 定位,可能需要 position-visibility 来避免「锚点已经滚出视口、面板还停在屏幕上」的诡异状态。
开发时把视口设小一点。很多翻转相关的问题只有在视口窄、面板贴着边界的时候才会暴露。开发者工具的响应式模式下把宽度调到 400 像素,拿几个面板测试一下,比在全屏下点着点着发现 bug 效率高得多。这条经验适用于所有和边缘定位相关的特性。
九、什么时候暂时不用它
锚点定位已经很成熟了,但有几类项目我建议暂时观望。
Firefox 仍是硬门槛的场景。如果用户群体里有相当比例在用 Firefox,或者产品明确要求跨浏览器一致,那前面描述的自动翻转能力还不能全盘依赖。这种情况下可以先用 Floating UI 之类的库做兜底,把 CSS 锚点定位作为增强——写一份基础的绝对定位样式,再用 @supports (anchor-name: --x) 包一层增强规则。这个组合能让你吃到新特性的好处,同时不掉队旧环境。
需要复杂碰撞检测的场景。比如一个下拉面板宽得像半个屏幕、又要避开页面里几个固定元素。这类逻辑 position-try-fallbacks 处理不了,还是得交给 JS。锚点定位的边界就是「围绕一个锚点的简单方位选择」,复杂约束不是它的目标。
至于判断标准,我的经验是:看这个面板是不是「追着一个元素走」。是,就用锚点定位;不是(比如居中弹窗),用 dialog 或者普通的固定定位。
十、写在最后
回过头看,锚点定位真正替代的不只是「一个定位库」。它把「哪个元素贴着哪个元素、贴着哪一边、空间不够往哪翻」这几件事,从运行时计算的逻辑变成了声明式的样式。声明式的好处是,这些规则跟着组件走——你不需要额外维护一份初始化代码,不需要在组件卸载时清理监听,也不会因为某个弹层的 DOM 结构在重构中变了一下位置就失效。
如果手头正好有个项目用了定位库,可以挑一个简单的场景——比如一个只在按钮下方弹出的菜单——把它先换成 CSS 方案。改完之后对比一下代码量,感受一下少了多少「计算坐标」「监听 resize」这一类胶水。剩下的复杂场景再考虑留着旧库,两边并存完全没问题。

