做前端的同学应该都遇到过这种需求:页面侧边栏或者首页顶部有一块公告区域,里面塞了几十条通知,可是可视区只够放三四行,产品要求自动向上滚动,而且是一圈一圈循环播放,不能滚到底部留白,更不能“唰”地一下跳回顶部。这种效果在很多UI稿里叫法不一,有人叫无缝滚动,有人叫首尾相接滚动,有人直接叫跑马灯。真正动手写的时候,第一次做的人几乎都会卡在同一个地方:滚动到底以后怎么办。这篇文章不讲花架子,直接从原理、代码、踩坑到取舍,把我自己从第一版到稳定版的完整思路写清楚。
1. 先把“无缝”这件事讲清楚:为什么普通的向上滚动总会露馅
1.1 两种常见滚动播报,需求其实完全不同
我在不少项目里都见过“滚动播报”这个需求被一句话带过,但真正拆开看,至少分两种。
第一种是普通的自动滚动:内容滚完一遍就停住,后面的看不到就看不到了。这种方案用 overflow: hidden 加一个定时改变 scrollTop 的 JS 就够了,简单直接,但展示效果有限,尤其当滚动内容多、播放时间短时,用户没看完就没了,产品通常不会满意。
第二种就是本篇要说的首尾相接滚动:内容数量超出可视区,要求自动向上滚动,并且滚完最后一屏后,视觉上继续从头开始,中间不能有断层、不能有白屏、不能让用户看到“跳回去”的过程。这类需求常见于公告栏、排行榜、消息通知、营销活动横幅。
很多人在实现第二种的时候,第一版方案是:JS 里判断 scrollTop >= scrollHeight - clientHeight,然后瞬间把 scrollTop 设回 0。这样做的效果是能循环,但用户体验很差,因为滚动是匀速连续播放的,到底之后突然跳回顶部,人眼对瞬间位移是高度敏感的,哪怕只隔了零点几秒,也会觉得“闪了一下”。
1.2 首尾相接的关键:让“结尾”看起来像“开头”
要解决“跳回顶部被看见”的问题,最常用的思路不是把回到顶部的动作藏起来,而是让视觉上根本不存在“回到顶部”这个动作。
怎么做?想象一下一个循环播放的胶片电影:胶片本身不是一条无限长的带子,而是一圈首尾粘合在一起的圆环,播放头永远走不到终点,因为它没有终点。我们前端要模拟的就是这个“粘合”的动作。
具体到 CSS 实现里,思路其实很朴素:把要滚动的列表内容复制一份,放到第一份内容的后面。然后让列表整体向上移动,移动到刚好走完第一份内容时,也就是位移到了原本列表总高度的 50% 处,马上让动画从头开始。由于第二份内容和第一份内容长得一模一样,动画归零回到列表初始位置时,用户看到的画面恰好是第二份内容的开头,和第一份内容的开头长得完全相同,视觉上根本无法察觉这里其实发生了一次“瞬移”。
这就是整个效果的核心,我用很久才真正想明白它为什么能行:不是把“结束”和“开始”连起来,而是把“中间某处”和“另一份的同一个中间位置”连起来,利用内容的重复制造连续性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心实现:复制一份列表,用 transform 让动画在50%处偷天换日
2.1 不是魔法,是百分比位移的小把戏
准备好了,下面是正题。先给一个最简单的 HTML 结构,我把关键点写在注释里。
html复制<div class="scroll-widget">
<ul class="scroll-list">
<li>【公告】系统将于本周六凌晨进行升级维护</li>
<li>【活动】新用户注册即可领取30元无门槛券</li>
<li>【通知】端午假期发货时间调整安排</li>
<li>【更新】移动端首页改版上线,体验更流畅</li>
<li>【公告】客服服务时间调整为9:00-21:00</li>
<!-- 注意:这里把上面的内容原封不动复制一份 -->
<li>【公告】系统将于本周六凌晨进行升级维护</li>
<li>【活动】新用户注册即可领取30元无门槛券</li>
<li>【通知】端午假期发货时间调整安排</li>
<li>【更新】移动端首页改版上线,体验更流畅</li>
<li>【公告】客服服务时间调整为9:00-21:00</li>
</ul>
</div>
核心样式:
css复制.scroll-widget {
height: 120px; /* 可视高度,只露出 3 行 */
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.scroll-list {
margin: 0;
padding: 0;
list-style: none;
animation: scroll-up 6s linear infinite;
}
.scroll-list li {
height: 40px;
line-height: 40px;
padding: 0 16px;
font-size: 14px;
color: #333;
border-bottom: 1px solid #f3f4f6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@keyframes scroll-up {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
动画里 translateY(-50%) 是这招的灵魂。这里的 50% 不是基于父容器,而是基于 .scroll-list 自身的总高度。因为列表里有两份相同的内容,总高度是单份内容的两倍,所以 -50% 正好精确地向上移动了“一份内容”的高度。动画从开始到结束,列表向上滚动了完整的一份内容;结束的瞬间,动画回到 0%,列表瞬间回到初始位置。由于第二份内容已经顶替了第一份内容的视觉位置,用户看不出任何变化。
2.2 一份能直接照抄的完整示例
上面只是最基础的部分,实际项目中一般还需要加背景色、边框、字号适配、去掉列表默认样式这些细节。我把这些打包成一个更完整的版本,可以直接在本地打开验证:
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSS 首尾相接上下滚动</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f8fafc;
padding: 40px 20px;
}
.demo {
max-width: 420px;
margin: 0 auto;
}
.demo-title {
font-size: 16px;
color: #1e293b;
margin-bottom: 12px;
font-weight: 600;
}
.scroll-widget {
height: 120px;
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.scroll-list {
margin: 0;
padding: 0;
list-style: none;
animation: scroll-up 6s linear infinite;
}
.scroll-list li {
height: 40px;
line-height: 40px;
padding: 0 16px;
font-size: 14px;
color: #334155;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@keyframes scroll-up {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
</style>
</head>
<body>
<div class="demo">
<p class="demo-title">站内公告</p>
<div class="scroll-widget">
<ul class="scroll-list">
<li>【公告】系统将于本周六 02:00-06:00 升级维护</li>
<li>【活动】新用户注册即可领取 30 元无门槛券</li>
<li>【通知】节假日发货时间调整,请合理安排收货</li>
<li>【更新】移动端首页改版上线,体验更流畅</li>
<li>【公告】客服服务时间调整为 9:00-21:00</li>
<li>【公告】系统将于本周六 02:00-06:00 升级维护</li>
<li>【活动】新用户注册即可领取 30 元无门槛券</li>
<li>【通知】节假日发货时间调整,请合理安排收货</li>
<li>【更新】移动端首页改版上线,体验更流畅</li>
<li>【公告】客服服务时间调整为 9:00-21:00</li>
</ul>
</div>
</div>
</body>
</html>
这个版本已经能跑出非常顺滑的首尾相接效果。需要注意,scroll-widget 的高度是可变的,你要根据你设计稿里可视区有几行来定;scroll-list li 的 height 和 line-height 也必须和实际内容高度匹配,不然行数算不准,后面会出现各种莫名其妙的问题。
2.3 为什么优先用 transform,而不是 margin-top 或 top
我见过一些人用 margin-top 或者 top/absolute 来做这个动画,能实现,但效果和性能都不如 transform。原因主要有这几个:
- 性能:
transform的位移是由浏览器的合成器处理的,它只触发布局阶段之后的“合成”过程,不会引起回流和重绘,对 CPU/GPU 的占用比修改margin-top或top小很多,尤其是在移动端,差距非常明显。 - 百分比参照:
margin-top的百分比是相对于父容器的宽度计算的,不是你元素自身的高度,用起来绕,容易出错;而transform的百分比是相对于元素自身尺寸的,正好能让-50%这种精确控制变得极其自然。 - 可组合性:
transform可以很容易地和translateX、scale、rotate组合使用,以后如果你想在滚动列表上叠加缩放或位移动效,不需要重构代码。
所以我的建议很简单:凡是做位移类 CSS 动画,能上 transform 就上 transform。 这不是炫技,是实打实的性能优化。
3. 上点难度:速度、悬停、错峰,三个高频需求一次解决
3.1 悬停暂停:鼠标放上去就停下来
公告类的滚动,用户最讨厌的一点是“我想看某一条,它却滚走了”。所以产品通常都会要求:鼠标悬停在滚动区域时,动画暂停。
纯 CSS 实现这个功能只要一行:
css复制.scroll-widget:hover .scroll-list {
animation-play-state: paused;
}
这里用到了 animation-play-state 属性,它可以在动画运行过程中实时控制播放或暂停。不用 JS,不用额外监听鼠标事件,对用户来说交互是即时的,体验很好。
值得提醒的是,移动端是没有 hover 概念的。你在 PC 端写的 :hover 在手机上表现为手指触摸时才触发,而且触摸后不一定一直保持“悬停”状态。所以如果你要做移动端的暂停交互,更可靠的是用 touchstart / touchend 或者直接给区域加一个点击暂停的开关按钮,这个属于 JS 范畴了,先不展开。
3.2 速度与方向的调整
速度的控制非常简单,就是改动画时长 animation-duration。比如原先是 6 秒滚完一份内容,你想让它更快,改成 3 秒;想让它更舒缓,改成 10 秒。
但这里有一个细节:如果内容只有三条,滚动得很快,你会觉得动画节奏很急促;如果内容有几十条,6 秒滚动一屏,又可能觉得太慢。所以速度应该综合考虑“一条内容停留多久”和“一条内容滚动位移多少”的关系。你可以这样估算:一份内容有 n 条,每条高 h 像素,可视区露出 m 条,那么单条内容从可视区区底部完全出现到顶部完全消失,大约需要 动画时长 / n 秒。一般来说,单条内容停留 2~4 秒比较合适。
方向调整也简单,有两种思路:
- 改 keyframes:把
translateY(-50%)改成translateY(50%),同时列表顺序反过来,或者transform-origin做相应调整。 - 保留原 keyframes,给
.scroll-list加animation-direction: reverse,等价于播放方向反向。
通常我建议保留 keyframes 不动,用 animation-direction: reverse 这类方向属性去调,这样以后想快速切换方向,只改一个属性就行。
3.3 多条滚动列表错开节奏
如果页面上同时有两块滚动区域,比如一块是公告,一块是实时销售数据,两块内容同时滚动时,视觉上会互相干扰,看起来像“一起在动”,甚至容易让人产生眩晕感。这时可以通过不同的动画时长或者设置 animation-delay 让它们错峰播放。
给第二个列表加上一个负的延迟:
css复制.scroll-list-secondary {
animation: scroll-up 6s linear infinite;
animation-delay: -3s; /* 负延迟:让动画从中间某个时间点开始播放 */
}
这里有个容易踩的坑:animation-delay 如果是正值,列表一开始会停留一段时间再开始滚动,很多场景下看起来像“慢了半拍”;如果是负值,它会让动画从已经进行到一半的状态开始播放,看起来就像页面一打开它就已经在滚了。想要多个列表在时间轴上错开,第一反应应该用负延迟,而不是正延迟。
4. 从“能滚”到“不露馅”:跳变、空隙、卡顿的排查记录
4.1 动画结束时的猛跳:为什么你感觉像闪屏了
先说一个最典型的 bug:动画确实在循环,但每到最后一帧向第一帧跳转的时候,总能看到“顿了一下”或者“闪了一下”。如果你也遇到这个现象,多半是以下两个原因之一。
第一个原因是 transform 位移没到位。比如你原本应该有 translateY(-50%),但写成 translateY(calc(-100% + 某个值)) 或者用固定像素值,移动的终点和单份内容的尾部没有精确对齐。因为复制出来的第二份内容和第一份内容存在偏移,跳到起点时,第二份内容的开头和第一份内容开头不在同一个视觉位置,人眼就能捕捉到错位。
第二个原因更隐蔽:列表项的分隔线、边框不一致导致的视觉跳变。如果每一条 li 都设置了 border-bottom,那么第一份内容的最后一条有边框,第二份内容的第一条开头没有边框;当动画瞬间从第二份的尾部跳回第一份的头部时,第一行上面可能多出一条细分隔线,或者少一条,视觉上就“抖”了一下。
解决办法也很简单:把分隔线设置在 li 的顶部 border-top,或者干脆用一个统一的背景色和间距来代替边框。不管用哪种,原则是两份内容之间所有可见样式必须完全一致,不能第一份的最后一项和第二份的第一项之间出现额外或缺失的元素。
4.2 两项内容之间的间距忽大忽小
这个坑我在新手阶段踩得很深。当时我给列表项设置了 margin-bottom: 12px,结果滚动起来的预览图里,第一份内容的最后一项和第二份内容的第一项之间的间距明显比其他项之间的间距大,视觉上就像被硬生生切了一刀。
原因很简单:margin 在常规垂直布局里会发生“外边距折叠”,两块内容之间的间距是两者 margin-bottom 和 margin-top 取最大值,不是简单的相加;更关键的是,两份内容拼接在一起时,拼接处的间距由第一份最后一项的 margin-bottom 和第二份第一项的 margin-top 共同构成,而列表内部其他项之间也有相同的 margin-bottom,但相邻项没有额外的 margin-top,于是拼接处就会比正常间距多出一截。
处理方式有两种:
- 不要使用 li 的
margin作为项目间距,改用padding。因为padding包含在元素高度内,两份内容拼接时不会产生折叠问题。 - 如果非要用
margin,那就给列表项统一设margin: 0 0 12px,并且第二份内容的第一项也要有margin-top: 0,然后把拼接处的间距单独在 keyframes 位移里计算进去,但这样做很绕,不划算。
所以我给你的建议是:项目间距尽量用 padding 或 border,不要用 margin。 这样能省掉一大半拼接上的视觉问题。
4.3 移动端滚动发卡、掉帧怎么查
纯 CSS 动画虽然不是 JS 驱动的,但移动端低端机上依然可能出现掉帧。常见原因有这么几个:
- 没有用 transform,用了 top / margin 做位移。 这种老问题在低端 Android 上很致命,会反复触发回流重绘。
- 列表项里有大量复杂样式。 比如阴影、渐变、大量边框、背景图片,每帧合成时 GPU 压力大。建议把那些装饰性样式放在外层容器上,而不是每一行 li 上。
- 没有及时提示浏览器将列表提升为独立合成层。 可以加上一句
will-change: transform,让浏览器提前准备好独立的合成层。但要谨慎,will-change用多了会占用内存,只在确实需要滚动动画的元素上加,不要给所有元素都加。
如果在微信内置浏览器里测试,还要注意部分机型对 CSS 动画的 60fps 支持并不稳定。我的排查步骤一般是:先用 DevTools 的 Performance 面板看在动画期间有没有出现布局(Layout)和绘制(Paint)事件,如果有,优先检查是不是用了 top 或 margin;如果没有布局事件但仍然卡顿,再去看是不是列表项内阴影、渐变这类重绘制样式太多导致的。
4.4 需要动态增删数据时怎么办
另一个很常见的场景是:列表数据是接口返回的,可能随时增加、删除、排序。纯 CSS 方案里你必须在 HTML 里写两份内容,数据一变,两份都要同步更新,如果靠手工复制,极容易漏。通常的做法是:在服务端模板里循环两次输出,或者在前端框架里用一个数组 repeat 渲染两遍。
如果用的是 Vue,可以这样:
vue复制<ul class="scroll-list">
<li v-for="item in doubledList" :key="item.id">{{ item.text }}</li>
</ul>
doubledList 可以用 [...list, ...list] 生成。React 也类似,[...list, ...list].map(...)。
需要注意的是,重复渲染时 key 不要直接用同一条数据的 id,因为两份内容里 id 相同会导致 React/Vue 的 key 冲突警告。可以在 key 上做文章,比如把 item.id 加上“-first”和“-second”前缀。这个细节很多人忽略,但一旦忽略,列表更新时可能出现元素复用混乱,动画流程也会受影响。
5. 纯CSS方案的边界,以及和JS方案的取舍
5.1 纯CSS适合什么场景,不适合什么场景
纯 CSS 方案的优点是显而易见的:零 JS、代码简洁、不依赖框架、运行时性能好。但它也有明显的边界。
它适合这些场景:
- 内容量基本固定,不会频繁增删,比如公司公告、站内消息提醒。
- 页面要求极轻量,不想引入额外的滚动库或写复杂的 JS 逻辑。
- 交互比较简单,只有自动滚动加悬停暂停。
它不适合这些场景:
- 列表数据高度动态,每秒钟都在变,比如股票行情、实时订单流。纯 CSS 重复两份内容会让数据和视觉同步变得很别扭,你得保证两份内容始终一致。
- 需要支持用户中途手动滚动、拖拽、点击某一条跳转等复杂交互。CSS 动画虽然能暂停,但很难做到精确控制“用户滚到哪一屏”。
- 单份内容高度不足容器高度。如果只有一条公告,复制两份后总高度勉强是容器两倍,动画位移只有一半,但是那一半可能连一条内容都移不完,滚动过程会露出大段空白。这种场景要么加内容,要么换方案。
5.2 JS版的“底到顶”回跳,和CSS版有什么本质区别
很多人问我:那我用 JS 判断 scrollTop 到底,再设置 scrollTop = 0,不也一样吗?看起来功能类似,但两者在“回跳时机”的处理上完全不同。
JS 方案通常要监听滚动事件,在滚动容器触底后立刻重置 scrollTop。这个重置动作本身是瞬时的,用户看滚动画面会有一个“跳动”感。虽然可以通过一些 trick 消除,比如在触底前就悄悄把 scrollTop 减掉一屏高度,或者用 transform 配合 JS 计算,但代码复杂度会明显上升。CSS 方案则把“跳回”藏在动画循环里,因为动画是连续的、帧同步的,跳到 0% 的一瞬间,下一帧又是位移 0% 的画面,跳转过程发生在两帧之间,用户根本感知不到。
如果你确实需要用 JS 实现,我建议这样写一个基础的版本:
js复制const wrapper = document.querySelector('.scroll-widget');
const list = wrapper.querySelector('.scroll-list');
function scrollLoop() {
const scrollDistance = list.scrollHeight - wrapper.clientHeight;
if (wrapper.scrollTop >= scrollDistance) {
// 先扣除一屏可见内容的高度,制造“第二份”的效果
wrapper.scrollTop -= scrollDistance;
}
// 继续滚动
wrapper.scrollTop += 0.5;
requestAnimationFrame(scrollLoop);
}
scrollLoop();
但说实话,除非你有强烈的交互需求,否则没必要用 JS 代替纯 CSS。它更多是为了说明“JS 做这事需要处理更多边界”,而不是鼓励你去替换。
5.3 顺带提一句滚动驱动动画这个新方向
既然都在聊 CSS 滚动效果,顺便提一个比较新的方向:CSS 的滚动驱动动画(Animation Timeline)。在支持 animation-timeline: scroll() 的浏览器里,你可以让动画进度直接绑定到某个滚动容器的滚动位置,而不是依赖时间轴播放。理论上它能做很多以前需要 JS 判断滚动位置才能实现的效果,比如滚动到某个区域时列表自动进入/退出播放状态。
不过这个特性的兼容性和规范还在变化中,现阶段生产环境使用风险较大。如果你在写探索性的项目,可以尝鲜;如果是要上线的商业项目,还是安安稳稳用现在这套复制内容 + transform 的写法。等技术稳定了,再考虑迁移也不迟。
从我自己的经验来说,这类“看似简单”的 UI 效果,最难的不是写出来,而是把边缘细节全部处理掉。上面这份方案我已经在多个项目里用过,稳定性和视觉效果都达到了产品预期。如果只是要在页面上放一块自动循环播放的公告栏,按这篇文章里的代码抄一遍基本就够用了;如果你后面遇到更特殊的需求,比如“首尾相接 + 左右循环”的组合,或者“滚动时列表项高亮联动”,只要理解了这份原理,改起来也不会太费劲。
