
做前端的人多少都遇到过这种需求一个页面上的 banner 区域要能自己滑动也得让用户能点着圆点手动切。第一反应通常是引一个轮播组件库或者手写一段 setInterval 加 translateX 的脚本。但真到项目里往往会发现为了三张图引一个几十 KB 的组件不太划算而且脚本轮播在标签页切到后台时还在跑定时器回来的时候动画已经错位了。纯 CSS 轮播图就是在这个缝隙里长出来的方案——它把什么时候切这件事交给keyframes的时间轴把切到哪一张交给单选按钮的:checked状态一行 JS 都不需要。这套东西在上古时代受限于选择器的能力只能做很丑的版本现在有了:checked、兄弟选择器、animation-play-state这些能力能做到自动轮播、手动轮播、悬停暂停、指示点联动全都齐活。下面我就把这套机制从原理到代码完整拆一遍包括我踩过的几个坑比如 flex 子项被压缩导致的整体缩水、移动端容器高度塌陷留下的黑边、还有动画和 transform 抢同一个属性时的优先级问题。1. 纯 CSS 轮播图到底能走到哪一步先把话说在前面纯 CSS 轮播不是万能的它有明确的能力上限。搞清楚边界比急着敲代码重要得多。很多人做了一半发现手动点了之后自动播放就再也回不来了然后开始怀疑是不是自己哪儿写错了——其实不是这是纯 CSS 方案的固有特性得用额外的状态位去补。1.1 一个不需要 JS 的切换方案代价是什么纯 CSS 轮播的全部逻辑都压在两个机制上一是keyframes驱动的自动位移二是input:checked配合~兄弟选择器驱动的状态锁定。前者负责时间到了就换后者负责用户点了就停在他点的那张。代价主要有三块。第一DOM 结构不能随便改隐藏的input必须和轨道元素处于同一层级并且排在它前面否则~选择器够不着第二手动接管之后想恢复自动播放必须额外准备一个自动状态的单选按钮因为单选按钮点下去了就没法用纯 CSS 取消第三无法做基于手势的拖拽切换——touchmove、惯性滑动、回弹这些效果纯 CSS 做不了只能靠scroll-snap那套滚动方案绕过去但那已经不算传统意义上的轮播了。所以判断标准很简单如果需求是固定几张图、自动轮播、点击圆点切换、鼠标移入暂停纯 CSS 完全够用而且体积几乎为零。如果需求里出现了拖拽、无限数据流、动态增删图片、或者要根据滚动深度做视差那还是老实写 JS。我自己的习惯是静态运营位用纯 CSS商品列表那种数据驱动的用组件。1.2 自动轮播与手动轮播其实是两套互斥的机制新手最容易犯的错是把自动和手动当成一件事去做想着一套transform同时管两边。实际上它俩的驱动方式完全不同。自动轮播的本质是动画——animation会按照关键帧的时间轴不断改写元素的transform它是一个持续运行、自己会往前走的过程。手动轮播的本质是状态——某个input被选中了于是对应的规则生效transform被固定在一个值上。关键点在于在 CSS 的层叠规则里动画产生的声明优先级高于普通的作者样式声明。也就是说只要animation还在跑你写多少条:checked ~ .track { transform: ... }都会被无视。这不是 bug是规范里定好的层叠顺序动画来源的样式排在普通声明之上只有!important能压住它。明白了这一点后面的所有设计就顺理成章了想让手动生效就必须先把动画关掉。这条结论后面第 4 节会展开讲怎么优雅地关。维度自动轮播手动轮播驱动来源keyframes动画:checked状态选择器位置由谁决定关键帧百分比相邻兄弟选择器里的固定值能否中途改变顺序靠改关键帧等于重写天然支持任意跳转暂停方式animation-play-state切到另一个 radio恢复自动天然循环需要额外的 radio 作为开关2. 自动轮播的时间轴百分比和时长怎么换算才不出错自动轮播的代码看起来就几行但真正难的是把关键帧的百分比算对。我见过不少轮播跑起来卡一下、快一下多半是百分比分配得不对或者是位移的百分比参照物理解错了。2.1 位移的百分比不是按容器宽度算的这是第一个反直觉的点。transform: translateX(-25%)里的百分比参照的是元素自身的宽度不是父容器的宽度。假设轮播容器宽 800px里面有 3 张真图加 1 张首图副本一共 4 个.slide。轨道.track用display: flex横向排列宽度设成400%也就是 3200px。每个.slide设flex: 0 0 25%算下来正好 800px跟容器一样宽。现在我要让轨道往左移动一张图的距离也就是 800px。换算成轨道自身的百分比就是800 / 3200 25%。所以translateX(-25%)刚好走一张translateX(-50%)走两张translateX(-75%)走三张。如果不理解这一点很容易写成translateX(-100%)结果整条轨道被甩出屏幕一片空白。我建议在草稿阶段就把这个换算写在注释里像这样/* 轨道宽 400%4 格一格 100% / 4 25% */ .track { display: flex; width: 400%; } .track .slide { flex: 0 0 25%; /* 每格占轨道的 25%即容器宽的 100% */ }提示轨迹宽度和子项宽度的百分比必须成倒数关系。轨道是N * 100%子项就必须是100% / N。这两个数字写错一个整个轮播就废了。2.2 滑动时间与停留时间的比例分配假设总周期 12 秒3 张图轮一圈那每张图平均 4 秒。但这 4 秒不是全用来滑的——如果一直滑图片永远在移动根本看不清。合理的分配是滑动 0.8 秒停留 3.2 秒。把时间换算成关键帧的百分比总时长 12 秒对应 100%所以 1 秒 8.333%。第 1 张停留结束3.2 秒 26.67%滑动到第 2 张在 4 秒 33.33%第 2 张停留结束7.2 秒 60%滑动到第 3 张在 8 秒 66.67%第 3 张停留结束11.2 秒 93.33%滑动到副本在 12 秒 100%不过我更推荐把滑动放在每段的开头写起来更顺手读起来也更符合直觉keyframes autoSlide { 0%, 18.33% { transform: translateX(0); } /* 0 ~ 2.2s 停在第 1 张 */ 25%, 43.33% { transform: translateX(-25%); } /* 2.2 ~ 3s 滑到第 2 张停到 5.2s */ 50%, 68.33% { transform: translateX(-50%); } /* 5.2 ~ 6s 滑到第 3 张停到 8.2s */ 75%, 100% { transform: translateX(-75%); } /* 8.2 ~ 9s 滑到副本停到 12s */ }每个关键帧节点写两个百分比的写法意思是在这个区间内值保持不变。例如0%, 18.33%表示从 0% 到 18.33% 一直保持translateX(0)真正的运动发生在 18.33% 到 25% 这一段。这是关键帧里最省事的写法不用手动补一段同值的重复声明。停留时长的经验值文字为主的运营位停留 3 到 4 秒比较合适用户能扫完标题纯图片的 banner2.5 到 3 秒就够了太久会显得页面发呆。滑动时长建议控制在 0.4 到 0.8 秒之间太短显得生硬太长会跟下一张的停留时间混在一起。2.3 无缝循环的临门一脚末尾复刻首图如果只按上面那样写播放到第 3 张之后动画会瞬间跳回起点——画面会从第 3 张啪地闪回第 1 张非常突兀。解决办法是在轨道的末尾再塞一张和第 1 张完全一样的图作为第 4 格。动画走到 100% 时停在translateX(-75%)显示的是这张副本循环重启回到 0% 时停在translateX(0)显示的也是第 1 张。因为两者内容完全一致用户看不到任何跳变无缝就成立了。这一步有几个必须注意的地方副本必须是首图的复制不是尾图。复制错位置的话循环时会从第 3 张跳回第 3 张中间少一张。副本最好加上aria-hiddentrue让读屏软件跳过它避免同一张图被念两遍。手动切换到第 3 张时副本是多余的但因为被轨道裁掉了不会露出来可以不管它。如果图片是懒加载的副本会触发一次额外的解码。建议给副本直接用同一张已经缓存过的图片地址别用不同尺寸的图。还有一种不用副本的做法把动画的收尾写成反向滑回起点。但那会让整个过程变成左左右右来回摆视觉上像在抖我不推荐。多一张图的 DOM 成本换一个干净的无缝循环这笔账很划算。3. 手动轮播的机关单选按钮和 label 的隔空对话手动轮播这套东西第一次看会觉得像变魔术——明明没有任何脚本点一下标签图就换了稍微有点不可思议。拆开看其实就是三个部件在配合隐藏的单选框、属性指向它的 label、以及靠~选择器搭上的轨道。3.1 隐藏的 input 为什么必须留在文档流里先明确结构。所有的input必须放在轮播容器的最前面并且和.track是兄弟关系不能包在别的元素里div classcarousel input typeradio namenav ids1 checked input typeradio namenav ids2 input typeradio namenav ids3 div classtrack ... /div div classdots label fors1/label label fors2/label label fors3/label /div /div为什么不能塞进.dots里因为~是后继兄弟选择器它只能选中同一父元素下、排在后面的兄弟。如果input被包进了.dots那它和.track就不是兄弟了选择器直接失效。那为什么不用相邻兄弟选择器因为只认紧跟着的下一个兄弟input和.track中间隔着别的东西就用不了。~宽松得多只要在后面就行写起来更稳。至于怎么隐藏这里有个坑要绕。直接display: none是最省事的写法图片能正常切但键盘用户按 Tab 会完全跳过这些选项无障碍直接废掉而且没法用:focus-visible给焦点做样式。我更推荐下面的写法既看不见又保留可聚焦性.carousel input[typeradio] { position: absolute; width: 1px; height: 1px; opacity: 0; clip: rect(0 0 0 0); pointer-events: none; }注意pointer-events: none是为了防止用户用 Tab 聚焦后按空格选中时浏览器把视口滚动到那个 1px 的元素上。如果发现聚焦后有奇怪的滚动把这个属性加上。3.2 label 的 for 与 ~ 选择器的定向通信点击 label 的时候浏览器会把它的for属性当作目标 id去激活对应的input。这是原生行为跟脚本无关。于是整个通信链路是点击label[fors2]→#s2被选中 →#s2:checked ~ .track匹配成功 → 轨道被赋予translateX(-25%)。对应的 CSS 就这么几条#s1:checked ~ .track { animation: none; transform: translateX(0); } #s2:checked ~ .track { animation: none; transform: translateX(-25%); } #s3:checked ~ .track { animation: none; transform: translateX(-50%); }注意这里的-25%、-50%和第 2 节算出来的是同一套数值因为参照物都是轨道自身宽度。千万别看着第 2 张就顺手写成-50%那是给 5 格轨道用的。另外animation: none这一句是必须的理由前面说过——动画优先级更高。少了这一句点圆点完全没反应这是排查时第一个要检查的地方。圆点本身的选中态样式也靠同一套选择器#s1:checked ~ .dots label[fors1], #s2:checked ~ .dots label[fors2], #s3:checked ~ .dots label[fors3] { background: #fff; transform: scale(1.2); }3.3 点击热区、光标和移动端的即时反馈圆点默认只有几个像素手指点起来很难受。我一般的做法是视觉上的小圆点用一个伪元素画真正的 label 撑到至少 32×32 的点击区域。.dots { position: absolute; left: 50%; bottom: 16px; display: flex; gap: 4px; transform: translateX(-50%); } .dots label { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; } .dots label::before { content: ; width: 10px; height: 10px; border-radius: 50%; background: rgba(255, 255, 255, 0.45); transition: transform 0.25s, background 0.25s; }这样做还有一个好处不用给 label 设背景色选中态直接改::before就行避免 32×32 的方框被染色露馅。移动端还有两个细节。一是cursor: pointer在触屏上没意义但在混合设备带触摸屏的笔记本上能让鼠标用户知道这是可点的建议保留。二是部分老安卓浏览器对 label 上的点击有 300ms 延迟加了 viewport meta 之后基本消失了如果真的遇到可以在容器上加touch-action: manipulation效果立竿见影。4. 自动与手动共存谁说了算以及怎么让位到这里自动和手动各自都能跑了但一合起来就是灾难现场——要么手动完全点不动要么点了之后再也回不到自动。这一节解决的就是这个博弈。4.1 animation 覆盖 transform 的真实原因先把结论坐实在一个元素身上keyframes里写的属性会覆盖普通样式里写的同名属性无论选择器的权重多高。/* 权重再高也没用 */ #s2:checked ~ .track { transform: translateX(-25%); } /* 只要这条还在生效上面那条就被忽略 */ .track { animation: autoSlide 12s linear infinite; }原因在层叠顺序里CSS 声明的来源分了好几层动画animation origin排在普通作者样式之上只低于带!important的声明。所以想压住动画只有两条路——要么给transform加!important不推荐会把整套优先级搞乱要么干脆把动画关掉。关掉动画的两种写法我都在项目里用过animation: none;—— 直接把动画清掉。清掉之后元素的transform完全由样式表决定。animation-play-state: paused;—— 动画还在只是冻在某一帧。这时候transform仍然被动画控制手动设置的值依然无效。所以做手动接管只能用第一种。第二种只适合做鼠标移入暂停。4.2 用第四个自动单选按钮做状态开关前面留了个尾巴手动点完之后怎么恢复自动播放答案是用第四个单选按钮它不参与切图只负责表示我要自动播放这个状态默认选中。div classcarousel input typeradio namenav idauto checked input typeradio namenav ids1 input typeradio namenav ids2 input typeradio namenav ids3 div classtrack ... /div div classdots ... /div /div因为name相同它们共用一个互斥组——同一时刻只有一个被选中。默认选中#auto动画默认在跑。一旦用户点了任意一个圆点#auto自动失焦动画的规则不再匹配/* 默认自动播放 */ #auto:checked ~ .track { animation: autoSlide 12s linear infinite; } /* 手动接管动画停位置锁死 */ #s1:checked ~ .track { animation: none; transform: translateX(0); } #s2:checked ~ .track { animation: none; transform: translateX(-25%); } #s3:checked ~ .track { animation: none; transform: translateX(-50%); }再给一个恢复自动播放的小按钮forauto指回去就行label classplay-btn forauto自动播放/label这套结构的好处是完全不需要判断用户有没有操作过状态机由浏览器自己维护。要注意的是用户点回#auto时动画会从 0% 重新开始也就是从第 1 张起步。如果用户刚看完第 3 张然后点自动画面会瞬移回第 1 张。如果这个体验不能接受可以给#auto配一个专门的关键帧让它从 -50% 起步再往后跑代价是至少要写三套关键帧。我一般不做这个优化因为真实场景里很少有人来回切。实操心得这套开关式 radio的写法不止能用在轮播上标签页、折叠面板、评分组件都可以套。核心就是用一个选中态表示当前视图剩下的交给 CSS。4.3 鼠标移入暂停的两种写法与差异桌面端几乎一定会要求鼠标悬停时暂停。最直接的是.carousel:hover .track { animation-play-state: paused; }但这条规则只在动画还在跑的时候有意义。用户手动接管的那些:checked分支里已经animation: none了hover 上去自然什么都不发生也不会报错符合预期。另一种写法是用:focus-within让键盘用户把焦点移进来时也能暂停.carousel:hover .track, .carousel:focus-within .track { animation-play-state: paused; }两个都写上成本很低但对手柄用户和键盘用户友好很多。还有一个容易忽略的点如果配合:hover暂停轨道上的transition会被一起暂停吗不会。animation-play-state只影响动画transition是独立机制不受影响。所以如果你的手动切换依赖 transition 做平滑过渡hover 状态下点圆点平滑效果依然正常。5. 实测踩坑黑边、闪白、缩水和错位前面讲的都是应该怎么写这一节讲写完为什么不对。这几个问题我在不同项目里都遇到过至少一次排查过程基本一致。5.1 flex 子项没写 flex-shrink 导致的整体缩水现象三张图全挤在可视区域里每张只有三分之一宽画面像被压缩过一样。原因.track是 flex 容器宽度 400%而它的父级.carousel只有 100%。flex 子项默认flex-shrink: 1也就是空间不够就按比例缩。轨道的宽度被父级限死后子项自然会缩成一团。修复就一个属性.track .slide { flex: 0 0 25%; /* 等价于 flex-grow:0; flex-shrink:0; flex-basis:25%; */ }flex: 0 0 25%里的两个 0 分别管不放大和不缩小中间的25%是基准尺寸。写简写比分开写三个属性更不容易漏。顺带一提flex-shrink这个问题在overflow: hidden的容器里特别隐蔽因为缩水之后没有溢出、没有滚动条视觉上只是图变小了很容易被当成图片本身的问题去查。5.2 移动端容器高度塌陷与残留黑边现象桌面端正常手机上容器高度变成 0或者图片上下各留一条黑边。高度塌陷的原因通常是给容器用了绝对定位的子元素做内容而容器自身没有明确高度。轮播容器的正确做法是给一个确定的比例.carousel { position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; }aspect-ratio现在的兼容性已经很好了。如果项目要兼容比较老的机型用经典的内边距撑高法.carousel::before { content: ; display: block; padding-top: 56.25%; /* 9 / 16 0.5625 */ }关于黑边要分两种情况看。如果黑边出现在图片的上方或下方多半是图片的宽高比和容器不一致而图片用了width: 100%但高度是 auto于是容器多出来的部分就露出了背景色。解决方法是给图片设height: 100%加object-fit: cover让它填满并裁切。如果黑边是在轮播切换的瞬间一闪而过那更可能是渲染层面的问题。轨道在做translateX位移时浏览器会把整个轨道提升为一个合成层某些移动端 WebView 在合成层边缘会渲染出 1px 左右的缝隙。几个常用的压制手段.track { will-change: transform; backface-visibility: hidden; transform: translateZ(0); } .carousel { overflow: hidden; background: #000; /* 即使露缝也是深色不刺眼 */ }把一个和主色调一致的背景色垫在容器上是成本最低的兜底方案。我一般会这么做因为它对性能零影响而且能覆盖掉各种说不清来源的 1px 缝隙。5.3 图片底部的幽灵间隙与切换闪白现象一图片下方莫名其妙多出 4 到 5 像素的空隙圆点被顶上去一点。原因img默认是inline元素会按行内盒模型排版基线下方会留出字形下沿的空间。解决方法是display: block一行搞定。.track img { display: block; width: 100%; height: 100%; object-fit: cover; }现象二快速切换时图片先变白再显示出来。这个多半是图片被重新解码了。如果同一张图在 DOM 里出现多次比如我们的首图副本浏览器可能把它们当成独立的图层分别处理。缓解的办法有两种一是让副本直接复用同一个图片 URL浏览器缓存命中会快很多二是给所有图片加上will-change: transform之外的属性时保持克制——will-change加太多反而会挤占显存导致更严重的掉帧。我更倾向于从源头解决把图片压到合适的尺寸单张控制在 200KB 以内并用object-fit: cover保证裁切一致。切换闪白很多时候是图太大了解码来不及。5.4 圆点和箭头的定位基准圆点跑到容器外面或者位置在不同屏幕上飘忽不定一般是定位基准的问题。.dots必须放在.carousel内部而.carousel要有position: relative。然后用绝对定位加transform做居中.dots { position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%); }这里有个细节不要用left: 50%配margin-left: -Npx的老写法因为圆点数量可能变N得手算。用translateX(-50%)让它自己居中跟数量无关。还有一点如果.dots也被卷进了轨道的 flex 布局里比如不小心写成了.track的子元素那它会跟着一起位移圆点就会莫名其妙地滑走。结构上一定要把.dots放在.track外面和它平级。6. 功能加法指示点联动、左右箭头和响应式基础版跑通之后剩下的都是锦上添花。但有些加法做起来比想象中麻烦比如让指示点在自动轮播时也同步高亮——这一步是真正区分能跑和好用的地方。6.1 让指示点在自动轮播时跟着跳手动模式下圆点的高亮靠:checked就能解决。但自动模式下没有任何input被点过圆点全是灰的而图在动看起来就很割裂。解决办法是给三个圆点各挂一套关键帧动画用延迟错开。先回顾一下第 2 节的时间分配总周期 12 秒第 1 张停留 0 到 2.2 秒第 2 张停留 3 到 5.2 秒第 3 张停留 6 到 8.2 秒副本也就是又一张首图显示 9 到 12 秒。换算成百分比2.2s 18.33%3s 25%5.2s 43.33%6s 50%8.2s 68.33%9s 75%。注意第 1 个圆点需要亮两段时间——开头那 2.2 秒以及副本显示的最后 3 秒因为副本就是首图。这一点很多人会漏掉导致每轮的最后一段圆点是全暗的。keyframes dot1 { 0%, 18.33% { background: #fff; transform: scale(1.2); } 18.34%, 74.99% { background: rgba(255,255,255,0.45); transform: scale(1); } 75%, 100% { background: #fff; transform: scale(1.2); } } keyframes dot2 { 0%, 24.99% { background: rgba(255,255,255,0.45); transform: scale(1); } 25%, 43.33% { background: #fff; transform: scale(1.2); } 43.34%, 100% { background: rgba(255,255,255,0.45); transform: scale(1); } } keyframes dot3 { 0%, 49.99% { background: rgba(255,255,255,0.45); transform: scale(1); } 50%, 68.33% { background: #fff; transform: scale(1.2); } 68.34%, 100% { background: rgba(255,255,255,0.45); transform: scale(1); } } #auto:checked ~ .dots label:nth-child(1) { animation: dot1 12s linear infinite; } #auto:checked ~ .dots label:nth-child(2) { animation: dot2 12s linear infinite; } #auto:checked ~ .dots label:nth-child(3) { animation: dot3 12s linear infinite; }用nth-child而不是for属性来选是因为for属性选择器写起来太长而这里顺序本来就是固定的。三个圆点的动画时长必须和轨道完全一致都是 12 秒百分比也要对齐否则会出现图到了但点没亮的错位。这套写法只有在#auto选中时生效。用户一旦点某个圆点#auto失焦圆点动画全部停掉改由:checked规则接管高亮两边不会打架。6.2 左右箭头在纯 CSS 下的两条路箭头比圆点麻烦因为箭头需要相对当前位置前进/后退而纯 CSS 里没有变量能表示当前是第几张。思路一每个圆点旁边配两个箭头。这个太丑等于把箭头数量翻倍。思路二用下一张的思路给每个状态配一个专门的箭头。三个状态就是三组箭头通过:checked切换可见性。做法是把箭头做成三份每组只在自己对应的状态下显示.arrows .pair { display: none; } #s1:checked ~ .arrows .pair-1 { display: block; } #s2:checked ~ .arrows .pair-2 { display: block; } #s3:checked ~ .arrows .pair-3 { display: block; }每组箭头里的for指向不同的目标。比如在第 1 张时左箭头回到#s3右箭头去到#s2在第 2 张时左箭头回#s1右箭头去#s3。这样写有点啰嗦但完全不需要脚本而且逻辑清晰。我的建议是如果只有三到四张图这么做完全可接受超过五张就别硬撑了箭头数量会爆炸还不如用圆点。思路三其实很多真实项目里圆点已经够用了箭头只是个锦上添花。如果产品能接受直接省掉箭头是最省事的决定。我做过好几个版本最后都砍掉了箭头因为移动端用户更习惯直接点圆点。6.3 图片适配与容器比例的统一最后说一个容易被忽略但影响很大的细节图片的比例。如果三张图分别是 16:9、4:3 和 1:1而容器固定 16:9那么用object-fit: cover时 1:1 的图会被上下裁掉不少主体可能正好被裁掉。我一般会做两件事。第一在内容层面约束素材比例运营位统一出 16:9 的图从源头避免。第二在代码层面给object-position一个合理的默认值比如center让人物类的图尽量保留中心区域.track img { object-fit: cover; object-position: center; }如果某张图确实需要不同的裁切位置可以给它单独加一个类名覆盖object-position。这是纯 CSS 方案里少数几个能精细控制的地方用起来很顺手。响应式方面容器宽度用max-width卡住上限高度用aspect-ratio自适应圆点的bottom用clamp()在小屏上缩一点.carousel { max-width: 960px; aspect-ratio: 16 / 9; } .dots { bottom: clamp(8px, 2vw, 20px); }7. 一套可直接复用的完整实现与调参清单把前面的东西拼起来下面是可以直接复制到项目里的版本。我把关键参数都做成了变量改数字就能调效果。7.1 完整 HTML 结构div classcarousel !-- 状态位必须在最前面且与 .track 同级 -- input typeradio namenav idauto checked input typeradio namenav ids1 input typeradio namenav ids2 input typeradio namenav ids3 div classtrack div classslideimg src1.jpg alt第一张/div div classslideimg src2.jpg alt第二张/div div classslideimg src3.jpg alt第三张/div !-- 首图副本构造无缝循环 -- div classslide aria-hiddentrueimg src1.jpg alt/div /div div classdots label fors1/label label fors2/label label fors3/label /div label classplay-btn forauto自动播放/label /div7.2 CSS 核心代码.carousel { --total: 12s; /* 一轮总时长 */ --gap: 18.33%; /* 停留阶段的结束点 */ position: relative; width: 100%; max-width: 960px; margin: 0 auto; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 10px; background: #0d0d0d; } /* 隐藏状态位保留可聚焦性 */ .carousel input[typeradio] { position: absolute; width: 1px; height: 1px; opacity: 0; clip: rect(0 0 0 0); pointer-events: none; } /* 轨道4 格每格 25% */ .track { display: flex; width: 400%; height: 100%; will-change: transform; } .track .slide { flex: 0 0 25%; /* 关键不许压缩 */ height: 100%; } .track img { display: block; /* 消除行内基线间隙 */ width: 100%; height: 100%; object-fit: cover; object-position: center; } /* 自动播放时间轴 */ keyframes autoSlide { 0%, 18.33% { transform: translateX(0); } 25%, 43.33% { transform: translateX(-25%); } 50%, 68.33% { transform: translateX(-50%); } 75%, 100% { transform: translateX(-75%); } } #auto:checked ~ .track { animation: autoSlide var(--total) linear infinite; } /* 手动接管 */ #s1:checked ~ .track { animation: none; transform: translateX(0); } #s2:checked ~ .track { animation: none; transform: translateX(-25%); } #s3:checked ~ .track { animation: none; transform: translateX(-50%); } /* 悬停或聚焦暂停 */ .carousel:hover .track, .carousel:focus-within .track { animation-play-state: paused; } /* 圆点 */ .dots { position: absolute; left: 50%; bottom: clamp(8px, 2vw, 20px); display: flex; gap: 2px; transform: translateX(-50%); } .dots label { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; } .dots label::before { content: ; width: 10px; height: 10px; border-radius: 50%; background: rgba(255, 255, 255, 0.45); transition: transform 0.25s, background 0.25s; } #s1:checked ~ .dots label:nth-child(1)::before, #s2:checked ~ .dots label:nth-child(2)::before, #s3:checked ~ .dots label:nth-child(3)::before { background: #fff; transform: scale(1.25); } /* 自动模式下的圆点同步关键帧见 6.1 节 */ #auto:checked ~ .dots label:nth-child(1) { animation: dot1 var(--total) linear infinite; } #auto:checked ~ .dots label:nth-child(2) { animation: dot2 var(--total) linear infinite; } #auto:checked ~ .dots label:nth-child(3) { animation: dot3 var(--total) linear infinite; } /* 键盘焦点可见 */ .dots label:focus-visible::before { outline: 2px solid #fff; outline-offset: 3px; } /* 尊重系统的减弱动效偏好 */ media (prefers-reduced-motion: reduce) { #auto:checked ~ .track { animation: none; } #auto:checked ~ .dots label { animation: none; } }7.3 参数速查与调整方向想改的效果改哪里注意事项一轮总时长--total圆点的三套关键帧时长必须同步改单张停留时间autoSlide的停留结束百分比停留段变长滑动段占比就要压缩滑动速度停留结束点到下一个整数节点的差值建议控制在 4% 到 8% 之间12s 下约 0.5 到 1 秒图片数量轨道 width、slide 的 flex-basis、位移百分比三者必须同步规律是轨道宽N*100%、子项100%/N、每格位移100%/N圆点大小::before的宽高同时要改 label 的宽高保证点击热区不小于 32px圆点位置.dots的bottom用clamp()做小屏自适应改图片数量是最容易出错的这里给一个通用的换算表省得每次都重新推真图数含副本总格数轨道宽度子项 flex-basis每格位移34400%25%25%45500%20%20%56600%16.6667%16.6667%最后再分享一个排查顺序。纯 CSS 轮播出问题时按这个顺序检查能覆盖九成以上的情况第一步看input是否和.track同级且排在前第二步看animation: none有没有写在:checked分支里第三步看flex-shrink有没有被设成 0第四步看位移百分比的参照物是不是轨道自身宽度第五步看overflow: hidden有没有漏在容器上。这五步走完基本就没有查不出来的问题了。我在实际项目里用了这套方案之后最直观的感受是页面初始化时少了一次 JS 执行和一次布局重排首屏内容出现得更早了。另一个意外的好处是它天然适配用户禁用了脚本的极端情况——虽然现在很少遇到但真有这种环境时一个能自己动的 banner 比一个空白区域体面得多。