CSS 锚点定位实战:不用 JS 算坐标,做 tooltip、下拉菜单和跟随高亮框

2026-10-15 0 985

上个月做一个新手引导,需求是在页面上挑几个元素,旁边挂一个浮层说明,底下再套个高亮框把目标元素圈起来。听起来不难,做起来全是细节:窗口 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') 删掉的那一刻,感受会挺明显的。

CSS 锚点定位实战:不用 JS 算坐标,做 tooltip、下拉菜单和跟随高亮框
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 css CSS 锚点定位实战:不用 JS 算坐标,做 tooltip、下拉菜单和跟随高亮框 https://www.taomawang.com/web/css/2940.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务