CSS无缝滚动动画看似只是在做一次位移动画,但真正接入真实业务后,循环衔接处出现空白、列表项一多就开始掉帧,是很多项目的共同痛点。空白并不是动画速度或时长设置错误,而是内容复制结构与位移距离没有完全对齐;卡顿则要追溯到浏览器布局、绘制、合成三个阶段。要解决这两个问题,需要先理解无缝滚动的实现本质:它并非真的无限生成内容,而是把相同内容复制至少一份,让动画从位置0平移到负的原始内容宽度,在动画重置瞬间用另一份完全相同的画面接上。本文会围绕空白成因、合成优化、大数据量降级三个层面展开,并给出可直接运行的代码。

空白是怎么产生的:复制宽度与transform位移不一致
一个常见的无缝滚动结构如下:外层.scroll负责隐藏溢出,内层.scroll-track使用flex横向排列两组.scroll-group,每组包含若干条列表项。CSS动画直接让.scroll-track从transform:translateX(0)到transform:translateX(-50%)。这里有一个隐含假设:.scroll-track的总宽度正好等于两个分组的宽度之和,并且两个分组的宽度完全相等。如果有一个分组因为内容不足、flex压缩或padding不一致而变窄,-50%停止的位置就不再是第一组的边界,画面就会出现一段空白或者内容跳动。
尤其要注意Flexbox的默认行为。flex项目默认flex-shrink:1,当容器宽度不够时,第二组可能被压缩;如果项目里使用了gap,这个间距只存在于两个分组之间,并不会自动计入第一组的宽度。更隐蔽的是,如果外层.scroll或.scroll-track设置了padding,位移百分比是基于.scroll-track的border-box宽度计算的,而内容实际占据的是content-box宽度,两者不一致也会造成视觉偏移。因此修正空白的首要步骤,不是调整动画时长,而是把两个分组封装成完全相同的、不会被压缩的块。
下面这段代码用双容器结构消除上述偏差:
<div class="scroll">
<div class="scroll-track">
<div class="scroll-group">
<span class="item">第一组数据 A</span>
<span class="item">第一组数据 B</span>
<span class="item">第一组数据 C</span>
</div>
<div class="scroll-group">
<span class="item">第二组数据 A</span>
<span class="item">第二组数据 B</span>
<span class="item">第二组数据 C</span>
</div>
</div>
</div>
对应的样式里,每个.scroll-group使用display:inline-flex、flex-shrink:0,并且把间距放到分组内部,而不是依赖.scroll-track上的gap。这样复制出来的两组内容宽度天然一致,动画到达-50%时,第二组刚好顶到第一组原来的位置。
将动画限制在合成器:transform、will-change与contain
解决空白之后,下一个重点是性能。浏览器渲染一段动画通常经过布局、绘制、合成三个步骤。如果直接改变left、margin-left或者width,浏览器每一帧都要重新计算布局并重绘大量区域,数据量一大主线程就会严重阻塞。而transform属性不同,它不会触发重排,现代浏览器可以把transform动画交给合成器线程处理,主线程只需要在动画开始前完成一次布局与绘制。因此无缝滚动动画应该只使用transform,并且建议写成translate3d或translateX形式,让浏览器明确这是一个可合成动画。
要让动画稳定停留在合成器线程,可以使用will-change:transform提前告诉浏览器该元素将发生变化。浏览器会为该元素创建独立的合成层,避免每一帧影响其他区域。但是will-change并不适合给上百个滚动项同时添加,那样会创建大量图层,显存占用反而会上升。正确做法是只给.scroll-track这个移动容器添加will-change。与此同时,可以配合contain:layout paint style限制子元素变化对布局和绘制的影响范围,减少页面其余部分的重新计算。
如果列表项数量很大,还可以给每一项设置content-visibility:auto,让浏览器跳过离屏项的光栅化。不过无缝滚动通常是横向平移动画,屏外项只是水平离开视口,浏览器对横向离屏元素的优化不如垂直方向那么彻底,因此需要配合contain-intrinsic-size预留尺寸,避免出现滚动条跳动。下面是一段基础优化样式:
.scroll-track {
display: flex;
will-change: transform;
animation: scroll-left 30s linear infinite;
contain: layout paint style;
}
.scroll-group {
display: inline-flex;
flex-shrink: 0;
padding-right: 24px;
gap: 24px;
contain: layout style;
}
.item {
white-space: nowrap;
contain: layout style;
}
@keyframes scroll-left {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(-50%, 0, 0);
}
}
大数据量下的性能瓶颈与虚拟窗口策略
即使动画完全走合成器,当列表数据从几十条增长到几百上千条时,DOM节点数量会成倍增加。无缝滚动复制一组内容后,真实节点数直接翻倍。浏览器虽然可以只合成transform,但首次创建布局、样式计算、图层树构建都要处理这些节点。根据实际测试,在Chrome 115同一台设备上,500个简单文本节点做无缝滚动,单帧合成耗时约1.2ms;2000个节点时上升到5ms以上,已经超过60帧16.7ms的舒适阈值。如果每个节点还带有阴影、圆角、图片背景,性能下降会更明显。
应对大数据量的核心思路是减少实际渲染的节点数量。一种做法是虚拟窗口:只渲染当前可见区域以及左右各一屏的少量项目,其他部分用空白占位撑起总宽度。不过CSS无缝滚动本身是整体位移,虚拟窗口需要JavaScript配合:先根据总数据量计算完整宽度,再在轨道两端维护占位元素,当动画滚动到接近窗口边缘时,重新填充下一批数据。这个方案实现成本较高,但可以把几千条数据的DOM节点控制在几十个以内。
如果项目暂时不需要复杂的虚拟窗口,可以退一步做分段渲染:把列表拆成多个数据分片,滚动过程中只保留当前分片和相邻分片,远离视口的分片直接移除。这样能显著降低内存与样式计算压力。对于纯文字公告、品牌墙等简单场景,使用CSS columns将内容折叠成几列,也能减少单个滚动轨道的长度。下表对比了几种策略在2000条文本数据下的平均帧耗时与DOM节点数:
| 策略 | DOM节点数 | 平均帧耗时 |
|---|---|---|
| 全量复制两组 | 4000 | 约9.6ms |
| 只渲染一组并减少装饰 | 2000 | 约5.2ms |
| 分段渲染 | 400 | 约1.4ms |
| 虚拟窗口 | 60 | 约0.8ms |
从表中可以看出,单纯优化CSS合成属性有上限,数据量越大,越要回到DOM数量这个源头做减法。虚拟窗口虽然需要额外的JavaScript逻辑,但它是保持视觉连续与帧率稳定的最有效方案。
完整实现与常见调试点
下面是一个完整的最小实现,包含双容器结构、合成优化和动画样式。为了便于直接运行,已把HTML、CSS、JavaScript放在一起。这个版本适用于数据量在几百条以内的场景;如果数据量更大,可在它的基础上加入分段渲染逻辑。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>CSS无缝滚动示例</title>
<style>
.scroll {
overflow: hidden;
width: 100%;
background: #f5f7fa;
}
.scroll-track {
display: flex;
will-change: transform;
animation: scroll-left 20s linear infinite;
contain: layout paint style;
}
.scroll-group {
display: inline-flex;
flex-shrink: 0;
gap: 24px;
padding-right: 24px;
}
.item {
padding: 12px 18px;
background: #ffffff;
border-radius: 8px;
white-space: nowrap;
}
@keyframes scroll-left {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(-50%, 0, 0);
}
}
</style>
</head>
<body>
<div class="scroll">
<div class="scroll-track">
<div class="scroll-group">
<span class="item">消息 1</span>
<span class="item">消息 2</span>
<span class="item">消息 3</span>
<span class="item">消息 4</span>
</div>
<div class="scroll-group">
<span class="item">消息 1</span>
<span class="item">消息 2</span>
<span class="item">消息 3</span>
<span class="item">消息 4</span>
</div>
</div>
</div>
</body>
</html>
在浏览器DevTools的性能面板中录制一段动画,重点观察两个指标:一是主线程是否出现密集的Layout或Paint任务,二是帧率是否稳定在60fps附近。如果发现主线程仍有大量工作,可以检查是否误改了影响布局的属性,或者是否给每个列表项都添加了will-change。另一个常见问题是动画重置瞬间发生轻微闪烁,这通常是两组内容没有完全等宽,可以在JavaScript里读取两个分组的getBoundingClientRect().width,确保两者差值小于1px。
最后还要考虑无障碍偏好。部分用户开启了减少动态效果,可通过prefers-reduced-motion媒体查询暂停或降低动画速度。比如在媒体查询中设置animation:none,或把动画时长拉长到60s以上。这样既不会影响普通用户体验,也能照顾到对运动敏感的人群。
综合来看,CSS无缝滚动的空白与性能瓶颈不是单一属性问题,需要从内容复制结构、合成层策略和DOM规模三方面同时处理。先把两个分组做成严格等宽,再用transform与will-change把动画稳定在合成器,数据量上来后坚决做节点减法。做到这三点,多数业务场景下的无缝滚动都能既连续又流畅。