上个月做一个新手引导,需求是在页面上挑几个元素,旁边挂一个浮层说明,底下再套个高亮框把目标元素圈起来。听起来不难,做起来全是细节:窗口 resize 要重算,页面滚动要重算,字体加载完导致高度变化要重算,弹层内容一变宽度又要重算。写到最后光算坐标的代码就快两百行,还漏了个横向滚动的情况。
这类需求有个共同点:位置本身是浏览器就能算出来的,我们却在用 JS 重复它的工作。CSS 锚点定位把这层逻辑收回了样式层,现在写起来是另一副样子。
它到底替换掉了什么
传统的做法大概长这样:
function place(target, popup) {
const t = target.getBoundingClientRect()
const p = popup.getBoundingClientRect()
let top = t.top - p.height - 8
let left = t.left + t.width / 2 - p.width / 2
// 上方放不下就翻到下面
if (top < 0) top = t.bottom + 8
// 右边超出就靠右对齐
if (left + p.width > innerWidth) left = innerWidth - p.width - 8
popup.style.transform = `translate(${left}px, ${top}px)`
}
addEventListener('scroll', () => place(a, b), true)
addEventListener('resize', () => place(a, b))
这段代码的脆弱之处在于,它假设”位置只跟滚动和 resize 有关”。但真实页面里,位置还会被这些东西影响:锚点元素是个自适应宽度的按钮,文案变了;父容器用了 flex 布局,兄弟元素增删了;页面用了 transform,getBoundingClientRect 拿到了缩放后的值;有个 ResizeObserver 在改变整个布局。想让这套监听不漏,得把 IntersectionObserver、ResizeObserver、MutationObserver 全加上,代码量直接翻倍。
锚点定位把这些都交给浏览器:你只声明”这个元素依赖那个元素定位”,剩下的跟随、翻转、裁剪,引擎自己处理。
最小可运行骨架
先看一个最简单的例子,一个按钮和它的浮层。
<button class="trigger">悬停我</button>
<div class="tip">这是一段提示</div>
.trigger {
/* 给它起个名字,相当于挂了个锚点 */
anchor-name: --my-trigger;
}
.tip {
position: absolute;
/* 声明它挂在哪个锚点上 */
position-anchor: --my-trigger;
/* 声明相对于锚点放在哪个位置 */
position-area: top;
}
三个属性,各管一件事,记清楚就不会混。
anchor-name:写在被参照的元素上,值是自定义标识符,必须以--开头。position-anchor:写在要定位的元素上,指出它依赖哪个锚点。position-area:写在要定位的元素上,说明相对锚点放在哪个格子。
这里有个反直觉的地方:定位元素仍然需要 position: absolute 或 fixed。锚点定位不是取代定位,是给绝对定位提供了一套新的参照系。锚点元素本身不需要任何特殊的定位设置。
位置是自动算好的
position-area: top 的含义是”把定位元素放在锚点正上方的格子里,并在水平方向居中”,间距也是浏览器帮你留的——它会自动避让,不会和锚点重叠。这跟你手写 top: -100% 完全不是一回事。
position-area 怎么读
锚点周围是一张 3×3 的九宫格,position-area 的两个关键词分别指定横向和纵向落在哪一列哪一行。
position-area: top; /* 上方居中 */
position-area: bottom; /* 下方居中 */
position-area: left; /* 左侧居中 */
position-area: right; /* 右侧居中 */
position-area: top left; /* 左上角 */
position-area: bottom right; /* 右下角 */
position-area: center; /* 完全覆盖在锚点上 */
关键字有物理方向和逻辑方向两套。写 top/bottom/left/right 是物理方向,写 block-start/block-end/inline-start/inline-end 是逻辑方向。做多语言项目的时候逻辑方向更稳妥,因为 RTL 布局下会自动镜像。
/* 这两个在从左到右的页面里效果一样 */
position-area: top;
position-area: block-start;
/* 但在 RTL 页面里,这个会跑到右边去 */
position-area: inline-start;
如果只给一个关键字,另一个轴默认是 center。position-area: top 等价于 position-area: top center。
还有一个特别实用的写法:span- 前缀。它让元素跨越多个格子,也就是说元素的一边和锚点的对应边对齐。
/* 从锚点左边开始往右铺,左边缘对齐 */
position-area: center span-right;
/* 常用于让下拉菜单跟按钮左对齐,但不限制宽度 */
position-area: bottom span-right;
下拉菜单用这个非常合适:菜单从按钮左侧开始,宽度由内容决定,不需要跟按钮等宽,但左边缘是齐的。
案例一:带箭头的 tooltip
先做一个完整一点的 tooltip。目标是悬停按钮时在上方浮出一段说明,带一个小三角箭头,上方空间不够时自动翻到下面。
<button class="tooltip-trigger">什么是锚点定位</button>
<span class="tooltip" role="tooltip">
用 anchor-name 把元素挂到另一个元素旁边,位置由浏览器计算。
</span>
.tooltip-trigger {
anchor-name: --tip-anchor;
}
.tooltip {
position: absolute;
position-anchor: --tip-anchor;
position-area: top;
position-try-fallbacks: flip-block;
max-width: 240px;
padding: 8px 12px;
border-radius: 6px;
background: #1f2933;
color: #f5f7fa;
font-size: 13px;
line-height: 1.55;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0.15s;
}
.tooltip-trigger:hover + .tooltip,
.tooltip-trigger:focus-visible + .tooltip {
opacity: 1;
visibility: visible;
}
/* 小三角,相对 tooltip 自身定位 */
.tooltip::after {
content: "";
position: absolute;
left: 50%;
bottom: -5px;
width: 10px;
height: 10px;
margin-left: -5px;
background: inherit;
transform: rotate(45deg);
}
几处值得说的地方。
position-try-fallbacks: flip-block 是回退策略。它会先试 position-area: top,如果上方放不下,就沿着块轴翻转,也就是改到下方。flip-inline 是在行内轴翻转(左右方向)。要两个方向都考虑,可以写 flip-block, flip-inline。
注意这里的逗号:逗号分隔的是”依次尝试的候选方案”,空格分隔的才是”同一个方案里的多个动作”。写 flip-block flip-inline 表示”先同时试上下翻转和左右翻转”,跟 flip-block, flip-inline 完全不是一个意思。
background: inherit 用在箭头上,这样改主题色时箭头自动跟着变,不用维护两处颜色。旋转 45 度之后,方块的下半部分会露在 tooltip 外面,形成小三角。
显示和隐藏用的是 opacity + visibility,不是 display: none。visibility 的好处是元素虽然不可见,但仍然存在,过渡结束后还能用 visibility: hidden 把点击事件收走。如果换成 display: none,过渡根本不会播放——当然现在也可以用 transition-behavior: allow-discrete 解决,但那是另一个话题。
还有个容易被忽略的细节:.tooltip-trigger:hover + .tooltip 用的是相邻兄弟选择器,所以 HTML 里 tooltip 必须紧跟在按钮后面。如果它被放在文档末尾(很多组件库习惯这么做,方便脱离层叠上下文),那就得改用 :has():
/* 按钮 hover 时,页面上对应的 tooltip 显示 */
.trigger-wrap:has(.tooltip-trigger:hover) .tooltip {
opacity: 1;
visibility: visible;
}
案例二:下拉菜单,配合 popover
锚点定位真正省事的地方,在于和 popover 配合。popover 会让元素脱离文档流进入 top layer,也就是不会被任何祖先的 overflow: hidden 裁剪。以前为了让下拉菜单不被卡片裁掉,要么用 position: fixed 加上一堆坐标计算,要么把菜单挂在 body 上。现在两个属性就解决了。
<div class="menu-wrap">
<button class="menu-btn" popovertarget="user-menu">账号设置</button>
<div id="user-menu" class="menu" popover>
<a href="/profile" rel="external nofollow" >个人资料</a>
<a href="/security" rel="external nofollow" >安全设置</a>
<button type="button">退出登录</button>
</div>
</div>
.menu-btn {
anchor-name: --menu-anchor;
}
.menu {
position: absolute;
position-anchor: --menu-anchor;
position-area: bottom span-right;
position-try-fallbacks:
flip-block,
flip-inline,
flip-block flip-inline,
--push-down;
margin: 6px 0 0;
min-width: 160px;
padding: 6px;
border: 1px solid #e4e7eb;
border-radius: 8px;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
/* 自定义回退:都放不下时,硬推到视口下边缘 */
@position-try --push-down {
position-area: bottom span-right;
top: auto;
bottom: auto;
margin-top: 6px;
}
关于这段代码,有几点必须说清楚。
popovertarget="user-menu" 和 popover 属性是配套的。前者让按钮点击时自动切换后者的显示状态,不需要写一行 JS。默认的 popover 是”轻量关闭”模式,点外面或者按 Esc 都会关掉,符合下拉菜单的交互预期。popover 属性还可以写成 popover="manual",那样点外面不会自动关,适合做常驻面板。
这里的 @position-try 只是个占位示例。真实的 @position-try 里写的是一整套位置规则,比如”改到下方、并且向右对齐”。上面那段更多是想说明自定义回退的语法:定义一个命名规则,然后在 position-try-fallbacks 里用 --名字 引用它。写自定义回退时不用再写 position-anchor,它会继承主规则里的锚点。
菜单的层级问题顺便解决了。popover 进入 top layer 之后,天然在所有普通内容之上,不需要动 z-index。这也是它比手写绝对定位舒服的地方——不用跟项目的层叠上下文打架。
案例三:跟随式高亮框
回到开头那个新手引导。需求是列表里 hover 到哪一项,高亮框就滑到哪一项外面。以前这里要监听每个元素的鼠标事件,拿到 rect 再设置高亮框的位置。
现在反过来做:让所有候选元素共用同一个锚点名字,然后只切换”当前指向哪个”。等等,这里有个细节要避开——如果多个元素用同一个 anchor-name,浏览器会挑其中一个(规范里是最后一个匹配的元素,但这个行为不建议依赖)。正确的做法是给每个元素一个唯一的锚点名,然后在高亮框上切换 position-anchor 指向谁。
<ul class="guide-list">
<li class="guide-item" data-anchor="--item-1">第一步:绑定账号</li>
<li class="guide-item" data-anchor="--item-2">第二步:导入数据</li>
<li class="guide-item" data-anchor="--item-3">第三步:邀请成员</li>
</ul>
<div class="guide-ring"></div>
.guide-item:nth-child(1) { anchor-name: --item-1; }
.guide-item:nth-child(2) { anchor-name: --item-2; }
.guide-item:nth-child(3) { anchor-name: --item-3; }
.guide-ring {
position: absolute;
position-anchor: var(--active-anchor, --item-1);
position-area: center;
width: anchor-size(width, --active-anchor);
height: anchor-size(height, --active-anchor);
border: 2px solid #3b82f6;
border-radius: 10px;
pointer-events: none;
transition: all 0.25s ease;
}
// JS 只负责切换状态,不参与任何坐标计算
document.querySelectorAll('.guide-item').forEach(item => {
item.addEventListener('mouseenter', () => {
const ring = document.querySelector('.guide-ring')
ring.style.setProperty('--active-anchor', item.dataset.anchor)
})
})
这段的关键是 anchor-size()。它读取锚点的尺寸,让高亮框自动和锚点一样宽一样高。anchor-size(width, --active-anchor) 的第一个参数是读哪个维度,第二个是读哪个锚点。不写第二个参数时,默认读 position-anchor 指定的那个——但写成变量之后最好显式带上,可读性更好。
另外注意高亮框写了 pointer-events: none,不然它会挡住下面列表项的 hover 事件,鼠标一动就来回闪。
JS 在这里只做了”哪个是当前项”这一个决定,位置、尺寸、跟随滚动全部由 CSS 完成。这是我认为锚点定位最有价值的用法:JS 管状态,CSS 管几何,两边不再互相干扰。
anchor() 函数:需要精确偏移时用
position-area 覆盖了大部分场景,但有些设计稿的间距是精确的,这时候用 anchor() 直接手动写 inset 更灵活。
.callout {
position: absolute;
position-anchor: --card;
/* 左边缘对齐锚点左边缘 */
left: anchor(--card left);
/* 顶部距离锚点底部 12px */
top: calc(anchor(--card bottom) + 12px);
/* 宽度跟锚点一样 */
width: anchor-size(--card width);
}
anchor() 的参数是”锚点名 + 锚点上的哪条边”。reference 可以省略,省略时用 position-anchor 指定的那个。常见写法是 left、right、top、bottom,也可以用 center 和百分比。
/* 放在锚点水平中心偏右 20px 的位置 */
left: calc(anchor(--card center) + 20px);
要注意 anchor() 只能用在 inset 相关的属性里(top、right、bottom、left、width 等),不能随便撒到别的地方。anchor-size() 则只能用在尺寸属性里。
几个坑,都是自己踩过的
1. position-anchor 不继承
如果定位元素里还有一层子元素也要跟着锚点定位,子元素得自己再写一遍 position-anchor。这个属性不会往下传。
2. anchor-name 重名会出奇怪结果
同一个页面上有两个元素都叫 --btn,定位元素会引用到哪个是不确定的,而且不同浏览器可能表现不同。命名带上业务前缀,比如 --card-avatar、--nav-toggle,能避免大部分冲突。
3. 属性改过名字
早期浏览器里 position-area 叫 inset-area,position-try-fallbacks 叫 position-try-options。如果项目要兼容稍旧一点的版本,可以两个都写:
.tip {
position-area: top;
inset-area: top; /* 旧版兼容 */
position-try-fallbacks: flip-block;
position-try-options: flip-block; /* 旧版兼容 */
}
不认识的属性浏览器会直接忽略,两个都写没有副作用。
4. 锚点不可见时的表现
锚点元素被 display: none 或者滚出视口之后,定位元素默认还留在原地。如果希望它跟着隐藏,加 position-visibility:
.tip {
/* 锚点不在视口里时,自己也藏起来 */
position-visibility: anchors-visible;
}
可选值有 always(始终显示)、anchors-visible(锚点不可见就隐藏)、no-overflow(被裁掉就隐藏)。做长列表里的 tooltip 时这个很实用,省得自己手动判断。
5. 滚动容器里的表现要实测
锚点定位会自动跟随锚点的滚动,这一点比手写计算舒服得多。但如果定位元素和锚点不在同一个滚动容器里,尤其是一个在 overflow: auto 容器内、一个在外,表现可能和你预期不同。做全局浮层的时候建议实际滚一遍确认。
6. 别忘了 pointer-events
定位元素如果覆盖了锚点,会挡住鼠标事件。像高亮框、指示器这类纯展示的覆盖层,记得加上 pointer-events: none。这个坑不限于锚点定位,但用它的场景里特别容易出现。
降级怎么写
锚点定位目前主流现代浏览器都已支持,但项目里总有需要照顾的旧环境。降级思路跟别的 CSS 新特性一样:默认给一个不依赖锚点也能看的位置,支持的时候再升级。
/* 默认:居中显示,不依赖锚点 */
.tip {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
opacity: 0;
visibility: hidden;
}
/* 支持锚点定位时,改成挂在目标旁边 */
@supports (anchor-name: --x) {
.tip {
position: absolute;
position-anchor: --tip-anchor;
position-area: top;
position-try-fallbacks: flip-block;
left: auto;
top: auto;
transform: none;
}
}
注意 @supports 块里要把默认的 left、top、transform 重置掉。如果只加新属性不清旧值,两套定位会叠在一起,元素跑到屏幕外都有可能——这是做降级时最常见的翻车方式。
用 @supports (anchor-name: --x) 而不是判断其它属性,是因为 anchor-name 是这套机制里最基础的属性,它支持了,其它通常也就支持了。
写在最后
我一直觉得锚点定位最舒服的一点,不是它省了多少行代码,而是它把”什么该由 CSS 管”这条线重新画清楚了。位置计算本来就不该是业务逻辑的一部分,它只是布局的延伸。
如果手头正好有一个 tooltip、下拉菜单或者新手引导在做,可以挑最简单的那一个先改成锚点定位试试。改完把那些 addEventListener('scroll') 删掉的那一刻,感受会挺明显的。

