为啥头像排成排总打架?试试这招让它们自动靠边站还带丝滑动画

3,673字
16–23 分钟
in

不少朋友做网页时都遇到过这种抓狂事:想搞一溜圆头像,稍微带点重叠效果看着更紧凑,结果屏幕一缩小就挤得变形,屏幕拉宽又散得亲妈不认。今天咱们就用点现代CSS的硬核新招,搞定这个自动伸缩的“叠罗汉”布局,顺带把悬停动画也安排得明明白白。

目录

摘要
这次咱们要整一个能自动适应容器宽度的圆形头像列表。关键点在于用 sibling-count() 动态计算头像数量,再配合 min() 函数算出自适应的负边距。利用 mask 属性裁剪出透明的重叠间隙,同时用 @property 注册自定义属性来实现丝滑的悬停过渡动画。整个方案无需写一行 JavaScript,完全靠 CSS 新特性搞定响应式布局。

弄个基本盘,让头像先排成排

先把 HTML 结构搭起来,就一个容器里塞一堆 img 标签。

<div class="avatar-list">
  <img src="avatar1.jpg" alt="头像">
  <img src="avatar2.jpg" alt="头像">
  <img src="avatar3.jpg" alt="头像">
  <!-- 想加多少加多少 -->
</div>

想让这些图片排成一行,最简单粗暴的就是给父容器上个 display: flex。再把图片弄成圆形的,用 border-radius: 50% 一刀切下去。这时候它们还老老实实排排坐,没啥重叠效果。

.avatar-list {
  display: flex;
}

.avatar-list img {
  width: 80px;
  border-radius: 50%;
}

要让它们“叠”起来,就得用负的 margin-right 把后面的图往前拽。比如设个 margin-right: -20px,后面的图就会往前蹭,盖住前面图的一小块。注意最后一张图得把 margin 归零,不然它会一直往前怼,把整个容器都撑变形。

.avatar-list img {
  margin-right: -20px;
}

.avatar-list img:last-child {
  margin: 0;
}

给头像剪个边儿,做出透明间隙

光靠负边距叠一起,后面的图会直接盖住前面的,看起来像叠罗汉。咱们想要的是那种每个头像都被“切”掉一小块,露出后面的透明背景,显得更有层次感。这活儿得靠 mask 属性来干。

radial-gradient 画个圆,把圆的中心点对准要切除的位置。比如从右侧切除,圆心的 x 坐标得定位到 calc(150% - 20px),这里的 20px 就是刚才负边距的数值。这样切出来的缺口刚好能让后面的图透过来。

.avatar-list img {
  margin-right: -20px;
  mask: radial-gradient(50% 50% at calc(150% - 20px),
        #0000 calc(100% + 10px), #000);
}

这里 #0000 表示完全透明,#000 表示不透明。calc(100% + 10px) 里的 10px 就是想让间隙多大就设多大。如果想让缺口在左边,就把圆心挪到 calc(-50% + 20px) 的位置。

让它自己会算账,屏幕咋变都不怕

上面的法子有个硬伤:负边距是写死的 20px。屏幕一缩,容器变窄了,这 20px 可能就太大,导致头像挤成一团甚至溢出。屏幕拉宽了,20px 又显得太小,重叠效果不明显。

咱们得让这个负边距自己会算数。数学公式其实不复杂:把容器多出来的宽度,平均分给每个头像之间的空隙。假设容器宽度是 100%,每个头像固定大小是 --s,头像数量是 N,那么每个头像(除了最后一个)需要的右边距就是 (100% - N * --s) / (N - 1)

N 这个数量怎么拿?以前得写死,或者用 JS 去数。现在 CSS 给了个新玩意儿 sibling-count(),它能自动统计父容器里同级元素的数量。配合 min() 函数,还能给这个计算值设个上限,比如不能超过 0(意味着最多紧贴,不能留正间距)。

.avatar-list {
  --s: 80px;
  --g: 10px;
}

.avatar-list img {
  margin-right: min((100% - sibling-count() * var(--s)) / (sibling-count() - 1), 0px);
}

试试把浏览器窗口从窄拉到宽,会发现头像之间的间距会自动调整,始终保证所有头像刚好填满容器,不多不少。这里有个坑需要注意,sibling-count() 目前只有部分浏览器支持,测试的时候最好用 Chrome 或 Safari 技术预览版。

把剪裁和间距绑定,让缺口自动对齐

现在间距会自己变了,但之前的 mask 里还写死了 20px,这就对不上了。得让 mask 里的圆心位置也随着间距动态变化。直接把刚才算出的 margin 值拿过来用。

.avatar-list img {
  --_m: min((100% - sibling-count() * var(--s)) / (sibling-count() - 1), var(--g));
  margin-right: var(--_m);
  mask: radial-gradient(50% 50% at calc(150% + var(--_m)),
        #0000 calc(100% + var(--g)), #000);
}

但是一刷新,发现图片全没了。为啥?因为 mask 里的百分比和 margin 里的百分比参照物不一样。margin 里的百分比是相对于父容器宽度,而 mask 里的百分比是相对于元素自身的尺寸。这就导致计算出来的数值对不上号。

解决这个问题得换个参考系,用容器查询单位 cqi(容器内联尺寸)。先把 .avatar-list 注册成一个容器:container-type: inline-size。然后把所有的 100% 换成 100cqi,让所有百分比都相对于同一个参照物——父容器的宽度。

.avatar-list {
  container-type: inline-size;
}

.avatar-list img {
  --_m: min((100cqw - sibling-count() * var(--s)) / (sibling-count() - 1), var(--g));
  margin-right: var(--_m);
  mask: radial-gradient(50% 50% at calc(150% + var(--_m)),
        #0000 calc(100% + var(--g)), #000);
}

这下完美了,窗口怎么缩放,裁剪位置都精准匹配。

整点丝滑小动画,悬停时让头像“归位”

悬停的时候让当前头像完全露出来,不叠着别人。逻辑很简单,把它的 --_m 从计算值改成 var(--g),也就是让边距变成正数,头像之间留点缝。

.avatar-list img:hover {
  --_m: var(--g);
}

但直接这么写,你会发现动画是突变的,硬邦邦。得让它过渡。直接给 --_m 加个 transition 是没用的,因为 CSS 不认为这个计算结果是可过渡的长度值。需要用 @property 把这个自定义属性正式注册一下,告诉浏览器它是个长度单位,可以平滑变化。

@property --_m {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

.avatar-list img {
  transition: --_m .3s ease;
}

加了 @property 之后,再悬停就能看到 --_m 从计算值平滑过渡到 var(--g),边距和裁剪位置同时动起来,效果杠杠的。

但是又发现一个新问题:悬停最右边那张图的时候,左边的图会集体往左挤,最左边那张图可能被挤出容器。这是因为原本的公式在悬停时少算了一张图的空间。得重新算一下,当某一张图悬停时,其他图的边距应该按“排除一张”来重新分配。

新公式得用 sibling-count() - 2 做分母,并且分子里还得减去悬停那张图占用的额外空间(一个 --s 加一个 var(--g))。最后算出来就是:

.container:has(:hover) img:not(:hover) {
  --_m: min((100cqw - var(--g) - sibling-count() * var(--s)) / (sibling-count() - 2), var(--g));
}

配合 :has() 选择器,就能实现“当容器里有图被悬停时,所有没被悬停的图都用这个新的边距”。这样就完美避免了溢出。

最后还得处理一下首尾两张图的特殊情况。它们本来就没边距,悬停的时候也应该保持原样,不需要触发其他图调整布局。用 :first-child:last-child 筛一下就行了。

.avatar-list:has(:not(:last-child):hover) img:not(:hover),
.avatar-list.reverse:has(:not(:first-child):hover) img:not(:hover) {
  --_m: min((100cqw - var(--g) - sibling-count() * var(--s)) / (sibling-count() - 2), var(--g));
}

这个 .reverse 类是用来处理缺口方向相反的列表,比如头像从右往左叠的情况,保证两边对称。