纯CSS打造奔跑骏马:从雪碧图到关键帧动画的完整实战
2026/9/15 3:33:28 网站建设 项目流程

有时候项目做久了,总会想搞点“花活”调剂一下。午休时间刷到“马年了还不会整活”这种标题,我一琢磨——确实,CSS折腾点小动画,是最好的放松方式,既能练手又出效果。今天就手把手带大家做一匹纯CSS驱动的奔跑骏马,不依赖任何JS库,也没有一张GIF动图,全靠CSS动画硬撑。这篇短文适合刚学完CSS基础选择器、想试试动画效果的入门选手,也适合写腻了业务样式、想找回一点“玩CSS初心”的老人。读完你不仅能复刻这匹马,还能顺手整出一套自己的“CSS整活方法论”。

1. 内容整体设计与思路拆解

1.1 为什么选“纯CSS跑马”这个项目练手

先说说选题逻辑。CSS动画,很多人在入门的时候都接触过transitionanimation,但真正能拿出来“整活”的作品并不多。为什么?因为日常业务里,动画大多是按钮悬停、弹窗过渡、滚动渐显这些“小动作”,并不需要把帧动画、分层运动、背景视差这些概念揉在一起。而制作一匹会奔跑的马,恰恰能把以下核心知识点全部串起来:

  • CSS关键帧动画(@keyframes)的写法与参数含义
  • 雪碧图(CSS Sprites)在动画中的正确打开方式
  • steps()阶梯函数与linear线性函数的本质区别
  • 定位与布局:绝对定位、transform配合位移的实战用法
  • 伪元素::before/::after减少冗余DOM节点的技巧
  • 无限循环动画的参数控制(infinite+alternate的组合效果)

换句话说,这个项目是“把CSS从入门到精通的零散知识点,揉成一个能跑的真东西”。做完你会突然发现,之前看教程时那些“懂了但不会用”的概念,全都打通了。

1.2 先定方案:雪碧图动画还是逐帧DOM动画

做帧动画通常有两条路:一条是用一张包含所有动作帧的雪碧图,配合background-position的位移来播放;另一条是把每一帧做成一整个DOM元素,通过切换显隐或位移来达到动画效果。

我的建议很直接:用雪碧图方案。理由有三:

第一,雪碧图只需一个元素,加载一张图片即可,性能开销远低于多个DOM节点轮番切换。第二,背景定位的位移计算是确定性的,只要帧数算对、步数设对,动画循环一定精准。第三,雪碧图是前端基本功,掌握后不光能跑马,做任何逐帧动画(人物行走、表情切换、UI反馈动效)都是一套通用逻辑。

可能会有朋友说:“那我直接用GIF不就行了。”确实,GIF很省事,但GIF在颜色数量、透明通道、尺寸控制上都有先天短板,而且无法在运行时通过CSS控制播放速度、暂停状态、方向反转。相比之下,CSS雪碧图动画才是更“可控”的整活方案。

1.3 动画分层的总体架构

整个页面我打算拆成三层来写,各司其职:

  • 第一层是“背景环境”,包括地面、太阳、远山,营造出一个开阔的奔跑场景
  • 第二层是“骏马本体”,采用雪碧图播放奔跑动作
  • 第三层是“动态点缀”,比如奔跑时扬起的尘土、身后的云朵移动,进一步强化速度感

这种“分层”思路,本质上和游戏开发里的前景、中景、背景视差是同一个逻辑。每一层独立运动,叠加起来就有“马在往前跑”的错觉。这个设计思路也方便大家后面自己扩展,比如想加个骑手、换背景风格,只需改动对应层级的HTML和CSS,互不干扰。

2. 核心细节解析与实操要点

2.1 雪碧图制作与帧动画原理

先来聊聊雪碧图本身。我们需要的不是一张随便截的骏马图,而是“动作分解图”。所谓动作分解,就是把一匹马奔跑的一个完整循环(比如四蹄腾空、前蹄落地、后蹄蹬地等几个关键姿态)按顺序横向排列在一张图上。每一帧就是马的某一个姿态,连续播放这些姿态,人眼就会产生奔跑的视觉残留。

常见做法是用Photoshop或在线工具把8帧、12帧或16帧的动作序列拼成一张长条图。帧数越多,动作越细腻,但图片体积也越大。做Demo的话,我建议8帧起步,既能看清动作变化,又不需要拼图拼到崩溃。

在CSS中播放雪碧图动画,核心是这样一个规则:

.m-horse { width: 200px; /* 单帧宽度 */ height: 200px; /* 单帧高度 */ background-image: url('horse-sprite.png'); background-repeat: no-repeat; animation: horse-run 0.8s steps(8) infinite; } @keyframes horse-run { from { background-position: 0 0; } to { background-position: -1600px 0; } /* 8帧总宽度,取负值 */ }

这里的关键,也是很多人第一次接触时最容易懵的点:steps(8)到底是干什么的。

steps(8)animation-timing-function的一个特殊取值。普通的linearease,会让背景位置在两个关键帧之间连续过渡,呈现出来的效果是“这张图被平滑地拖动”——你看到的是图片在快速滑动,而不是马的奔跑动作。而steps(8)会把整个变化过程切成8段,每一段内不做插值,直接跳跃到下一帧。这样一来,背景位置就会一格一格地跳,刚好对应雪碧图里的8个动作帧。

一句话总结:steps()就是“动画的顿挫感开关”,逐帧动画必须用它,不然全露馅。

2.2 定位方式选型:为什么推荐transform而非top/left

马的本体确定好后,要让马产生“向前奔跑”的位移感,通常有两种实现:一种是修改lefttop属性,一种是用transform: translateX()

很多新手写动画会直接改left,因为直观。但这里我建议使用transform。原因在于性能:left的改动会触发浏览器的重排(Reflow),也就是浏览器需要重新计算元素的位置和布局,再重绘页面;而transform只触发合成(Composite),不会影响其他元素的布局,性能开销小得多。

实测下来,在移动端低端机或者页面有大量背景动画的情况下,用left跑动画会出现明显卡顿,而transform基本能维持60帧满帧。这个差异在小项目里感受不明显,但一开始养成好习惯,后面做复杂动效会省很多心。

2.3 背景环境的营造:纯CSS能做的远比想象多

背景层我打算全部用CSS画,不引入额外图片。核心技能就是渐变、盒阴影和伪元素。

先看天空。一个简单的线性渐变就能做出从湛蓝到浅蓝的自然过渡。太阳可以用一个圆角元素加box-shadow的光晕效果来模拟。地面更简单,一个height有限的容器,背景用重复线性渐变拉出不断后退的斑马线。这些装饰不能占据太多代码量,但能瞬间把“光秃秃的马”变成“场景中的马”。

移动的云朵、被马蹄扬起的尘土颗粒,都是@keyframes加上透明度、位移、缩放这几个属性的组合拳。你会发现:纯CSS能实现的视觉丰富度,远超过大多数人的预期,关键是要敢于组合。

2.4 鼠标交互:让整活更进一步

有人问了:“用CSS实现动画,还能处理鼠标移入事件吗?”答案是能。CSS伪类:hover就是干这个的。

我打算让马在默认状态下自由奔跑,鼠标移上去时切换成“疾驰模式”——加快动画速度,同时让背景滚动更快,形成一种“马被追上就加速”的趣味交互。这个需求不需要JS,只需要在选择器上加一个:hover,再改变动画时长即可。

这里体现了CSS选择器体系里范围很大的一个知识点::hover不只作用于被悬停的元素本身,还可以通过父元素控制子元素。经常和“后代选择器”“子选择器”配合使用。所以别小看这个交互,它的本质是“父容器状态变化驱动多个子元素联动”,这个思路以后做导航悬浮、卡片特效都通用。

3. 实操过程与核心环节实现

3.1 第一步:准备HTML骨架

我们先搭一个极简的HTML结构,语义清晰、层级分明,方便后面灵活控制各层。

<div class="scene"> <div class="sky"></div> <div class="sun"></div> <div class="cloud cloud-1"></div> <div class="cloud cloud-2"></div> <div class="ground"></div> <figure class="horse"> <div class="horse-runner"></div> </figure> <div class="dust"></div> </div>

里面几乎所有元素都是空的,纯通过CSS来赋予它们视觉。figure作为马的语义化容器,内部有一个.horse-runner作为雪碧图的承载元素。为什么多套一层?因为有的时候我们需要把“动作动画”和“位移动画”分开处理:动作由内部元素播放雪碧图,位移由外部容器做平移,两者互不干扰。

3.2 第二步:全局基础样式

先写全局样式,把盒子模型统一,避免默认边距干扰。

* { margin: 0; padding: 0; box-sizing: border-box; } body { min-height: 100vh; display: flex; justify-content: center; align-items: center; background: #1a1a2e; font-family: system-ui, -apple-system, sans-serif; overflow: hidden; }

这里顺手用flex完成了页面居中,相当于顺带复习了“文本垂直水平居中”之外的场景——容器居中不只是文本能用到,整个舞台都能用。:root里我打算定义一些CSS变量,方便改主题色和统一动画时长:

:root { --horse-w: 200px; --horse-h: 200px; --frames: 8; --animate-duration: 0.8s; }

用变量管理尺寸和时长,最大的好处是后文可以一次性调整,动画调优时效率极高。

3.3 第三步:场景层——天空、太阳、地面与云朵

先铺背景,建立空间感。代码如下:

.scene { position: relative; width: 100vw; height: 100vh; overflow: hidden; background: linear-gradient(180deg, #87CEEB 0%, #E0F6FF 80%, #F5F5DC 100%); } .sky { position: absolute; inset: 0; } .sun { position: absolute; top: 60px; right: 80px; width: 80px; height: 80px; border-radius: 50%; background: radial-gradient(circle, #FFD700, #FFA500); box-shadow: 0 0 40px 10px rgba(255, 215, 0, 0.6); }

box-shadow的用法在这出现了两次:一次是做太阳的光晕,一次是后面给某些元素做“伪实体”效果。这是纯CSS视觉里最常用的招式之一,值得记下来。

地面是这类动画中最出效果的装饰之一。我采用重复线性渐变制造出“地面向后退”的视觉线:

.ground { position: absolute; bottom: 0; width: 200%; height: 160px; background: repeating-linear-gradient( 90deg, #8B7355 0px, #8B7355 60px, #A0522D 60px, #A0522D 120px ); animation: groundMove 1.2s linear infinite; } @keyframes groundMove { from { transform: translateX(0); } to { transform: translateX(-120px); } }

这里让地面本身以linear方式平移,平淡无奇,但配合马的动作就能形成“地面在向后滑动”的感觉。之所以宽度设成200%,是为了给translateX留出足够的位移空间,避免露出背景,可以理解为“跑道比马跑得远”。

云朵呢,我用伪元素来做,减少无意义的DOM节点:

.cloud { position: absolute; width: 180px; height: 60px; background: rgba(255, 255, 255, 0.8); border-radius: 50px; animation: cloudMove 30s linear infinite; } .cloud::before, .cloud::after { content: ""; position: absolute; background: rgba(255, 255, 255, 0.8); border-radius: 50%; } .cloud::before { width: 80px; height: 80px; top: -30px; left: 30px; } .cloud::after { width: 100px; height: 100px; top: -40px; left: 80px; }

当然,这只是最基本的云朵造型。有兴趣的可以多加几层::before或者干脆套多个子节点,模拟更立体的积云效果。原则不变:能用伪元素就绝不新增无意义的标签。

3.4 第四步:骏马雪碧图动画

接下来的重头戏。先看马的静态样式:

.horse { position: absolute; bottom: 100px; left: 50%; width: 200px; height: 200px; margin-left: -100px; z-index: 2; animation: horseMove 3s ease-in-out infinite alternate; } .horse-runner { width: 100%; height: 100%; background-image: url("https://example.com/horse-sprite.png"); background-repeat: no-repeat; background-size: 800% 100%; animation: horseRun 0.8s steps(8) infinite; }

这里background-size: 800% 100%比较特别。正常思维是“让雪碧图的每一帧显示为容器大小”,写background-size: cover或直接写200px 200px即可。但如果你想让容器尺寸随意调整,用百分比最省心。800%表示背景图宽度是容器的8倍,正好容纳8帧横向排列的图案,每一帧就能对齐到容器宽度。这个技巧在做响应式雪碧图动画时特别管用。

马的“原地奔跑”动画刚才已经写过,这里再补充位移的动画:

@keyframes horseMove { from { transform: translateX(-50px); } to { transform: translateX(50px); } }

这个位移是马整体的前后晃动,幅度不需要大,给画面增加一点呼吸感。而真正的“前进感”,是靠地面、云朵和尘土共同完成的——其实马始终在屏幕中间附近跑,但视觉欺骗下观众会觉得它一直在前进。这种“相对运动”的用法在动画和游戏领域极其常见,值得多想一层。

3.5 第五步:尘土飞扬与交互反馈

为了让奔跑更真实,我在马蹄附近加了一些尘土粒子。用伪元素和nth-child选择器分别控制几个小圆点的动画延迟,模拟尘土被先后扬起:

.dust::before, .dust::after { content: ""; position: absolute; bottom: 0; width: 20px; height: 20px; background: rgba(139, 115, 85, 0.5); border-radius: 50%; animation: dustPuff 0.6s ease-out infinite; } .dust::after { width: 14px; height: 14px; animation-delay: 0.3s; } @keyframes dustPuff { 0% { transform: translate(0, 0) scale(1); opacity: 0.8; } 100% { transform: translate(-60px, -30px) scale(2); opacity: 0; } }

鼠标移入的交互,利用.scene:hover来控制全局节奏:

.scene:hover .horse-runner { animation-duration: 0.4s; /* 马跑得更快 */ } .scene:hover .ground { animation-duration: 0.6s; /* 地面退得更快 */ } .scene:hover .cloud { animation-duration: 15s; /* 云也飘得更快 */ }

这里就是利用“父容器状态给子孙后代施加影响”,也正好呼应了热词里的“css 鼠标移入事件”。这种关系型选择器思路,在写组件库的时候尤其好用:一个开关状态,驱动多个子部件一起响应。

3.6 第六步:细节打磨与性能优化

动画完成后,个人建议做三件事:

第一,检查是否存在不必要的will-changewill-change: transform可以提示浏览器把元素单独提升一层,但也别滥用,滥用会导致内存占用暴增。我只会给常年动的.horse.ground加上。

第二,确认动画是否无限循环且无停顿。infinite是无限循环,但有些动画(比如尘土)在首轮开始前会有延迟空档,用animation-delay的负值(例如-0.3s)可以消除这个空档,让动画从中间某帧开始播放,视觉上无缝衔接。

第三,控制好z-index层级。场景中的元素按照“背景天空 -> 云朵 -> 太阳 -> 地面 -> 骏马 -> 尘土”的关系设定层叠顺序,避免出现马跑到地面后面的低级错误。这里隐藏的知识点就是z-index只对定位元素生效,很多新手在非定位元素上加z-index怎么调都没反应,就是这个原因。

测试完以上几项,效果已经很完整了。整段代码不依赖任何JS,浏览器打开即运行。你可以把代码丢进CodePen或本地HTML文件中直接看效果,建议同时打开Chrome DevTools的性能面板观察帧率,正常情况下应该是平稳的60帧。

4. 常见问题与排查技巧实录

4.1 雪碧图动画“鬼畜”而不是奔跑

这个现象十有八九是steps()参数和帧数不匹配。比如雪碧图有8帧,你却写在steps(6),那么背景定位会在8帧图片上跳6次,必然错位。

排查方法:先把animation暂停,手动设置background-position0 0-200px 0-400px 0等,逐帧确认单帧显示是否正确。如果手动切帧没问题,再回来检查steps()数值是否等于帧数,同时确认background-size的百分比是否和帧数对应一致。

4.2 动画循环时总有短暂“闪烁”

这种闪烁通常由两处引入:一是雪碧图最后一帧到第一帧之间,因为位移计算是0 -> -1600px,刚好覆盖8个位置,但如果总宽度算多或者算少,循环点就露馅了;二是background-color的默认值透出来。补救办法是给.horse-runner加一个和图纸底色一致的background-color,或者确认雪碧图本身完全覆盖容器。

另一种闪烁来源于元素透明度和位移组合时,浏览器合层偶发Bug。此时可以给动画元素加上translateZ(0)强制触发GPU合成,通常能缓解。

4.3 马跑起来了,但地面像在“蠕动”

出现这个情况说明地面滚动速度和马的摆动频率不匹配。理想的处理方式是调整地面动画时长,让它与马蹄落地的节奏大致对应。经验值是:马的雪碧图动画时长0.8秒,地面平移一个循环也建议控制在0.8秒到1.2秒之间,具体微调即可。

4.4 代码放上去没有动画效果

先看CSS是否成功引入。有三种方式:外部样式表、内部样式、内联样式。在做纯CSS小Demo时,用内部样式(<style>标签)最方便,不容易出现文件路径问题。其次是检查@keyframes名称是否和animation中的名称完全一致,这个看起来低级,但真的很容易犯。

再不行,打开DevTools,在Elements面板找到元素,看Styles里有没有出现删除线。如果出现过期的属性(如老旧的animation-timing-function写法),浏览器会直接忽略。

4.5 背景图片死活不显示

优先检查URL。如果是相对路径,确认HTML页面和图片的位置关系。本地调试时,我建议直接使用图片转Base64的方式或本地绝对路径,省得路径问题干扰你验证动画逻辑。如果你用的是在线示例图,务必确认图片支持跨域且资源未过期,否则建议先下载到本地再引用。

5. 从“跑马”到“思路自由”的进阶指南

到这里,一匹纯CSS奔跑的骏马就完成了。但比起照抄代码,我更希望你能从中抽出一套“CSS整活公式”,以后看到任何题材,都能快速拆解成可执行的小步骤。

我的个人经验是:任何CSS动画项目,先拆成“环境、主体、特效”三层,再分别问自己三个问题。环境层能用渐变或阴影画出来吗?主体层能拆帧吗,能用雪碧图实现动作吗?特效层需要几个伪元素,动画是位移、旋转、缩放还是透明度变化?问完这三个问题,再复杂的动画也能落地。

最后分享一个我实际踩过的坑:别在动画里过度使用选择器嵌套过深的结构。有时候为了样式,我们习惯写.scene .wrapper .container .horse-runner这种路径,但浏览器做样式匹配时会沿着层级一路查找,动辄几十个元素还好,一旦动画复杂起来,性能损耗会被放大。能用类名独立解决问题,就尽量扁平化结构。这一点对做大型前端项目同样适用。

现在,轮到你打开编辑器了。找一张骏马雪碧图,或者自己画一匹简笔马,跟着文章的思路做一遍。跑起来那一步,你会觉得CSS其实特别有意思。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询