
简介用纯CSS3绘制世界名画《蒙娜丽莎》的完整源码包面向前端开发者、网页设计师以及对CSS图形创意感兴趣的初中级学习者旨在展示不借助图片和Canvas、仅靠样式表实现高复杂度视觉艺术的可能性。压缩包内共2个文件一个CSS样式文件负责全部图形构建一个HTML页面文件作为载体整体大小仅50KB结构精简可直接在浏览器中运行便于逐行研究border-radius、渐变、阴影、clip-path、滤镜等属性如何组合出复杂图形。目前已有90人学习浏览。绘制过程运用线性与径向渐变还原色彩层次利用box-shadow与text-shadow塑造面部立体感通过transform与filter营造柔和边缘最终呈现蒙娜丽莎标志性的微笑与衣物褶皱。这种轻量、可定制、无外部依赖的创作方式能帮助读者快速上手CSS3图形化编程并为网页动效、艺术化交互设计提供新的实现思路。1. 看到“纯CSS3绘制的蒙娜丽莎画像源码.zip”时别急着笑这个标题放在任何前端群里都会有人觉得是玩笑CSS 不是绘画语言蒙娜丽莎也不是一句话能渲染出来的图形。但真正动手做过的人知道这恰恰是 CSS3 的box-shadow、渐变、变换和动画组成的极限测试。它不依赖 Canvas、不写 JavaScript、不引用任何外部图片只用 HTML 和 CSS 就能把一幅位图“编译”成样式表再压缩成 zip 离线打开。这类源码真正解决的不是“画得有多像”而是如何用纯样式描述点阵一张蒙娜丽莎按原画分辨率逐像素填box-shadow浏览器很快会卡死降到 60×40 后一条条阴影叠加起来就能看出脸型。对前端工程师它是理解浏览器解析长样式、合成大绘制区域的最好样本对可视化工程师它是把“数据”映射成“视觉元素”的练兵场对刚接触 CSS3 动画的人这套源码把延迟、完成状态、逆向播放全部放在一幅画上比任何抽象 demo 都直观。下面从一张位图开始讲到像素点阵、颜色量化、动画叠加和分块性能最后落在一个能验证、能交付的源码包上。2. 把蒙娜丽莎“翻译”成 CSS从位图到 box-shadow 与 clip-path 的转换流程2.1 像素密度决定工作量先决定尺寸再谈源码拿到项目标题里的“蒙娜丽莎画像源码”先别急着解压看 CSS。CSS 像素画像的输出量不是画工决定的而是“宽 × 高”决定的。蒙娜丽莎如果按原画 768×512 逐像素绘制那是 40 万条阴影浏览器解析样式时就会直接掉进重绘泥潭。所以这一类项目的第一张设计图是先决定降采样尺寸。常见做法是先把原图按比例缩到几十像素宽再逐像素取色。下面是几个我常用的目标尺寸区间目标尺寸box-shadow 条目数生成 CSS 体积估算能看到什么40×30约 120030-50 KB轮廓、姿势和基本肤色60×40约 240060-100 KB脸部明暗、明显微笑80×52约 4160120-180 KB眼睛、嘴角细节已可认出蒙娜丽莎120×80约 9600350 KB 以上接近粗像素照片生成和渲染都会变慢这个体积估算受调色板数量影响但核心结论不变超过 8000 个阴影后单元素绘制会明显拖慢首屏。多数纯 CSS3 画像源码选择 80×52 或 64×40不是因为不能再大而是需要在“可辨识”和“可打开”之间取平衡。尺寸定下来后再考虑原来的 JPEG 或 PNG 怎么变成 CSS 文本。手写是不可能的手工在 HTML 里摆几百个 div 也没有意义。常规方案是用 Python 脚本读图、降采样、量化颜色再拼出 box-shadow 字符串。2.2 生成工具Python 脚本把 PNG 转成 CSS 样式下面是一个最小可用的生成脚本依赖 Pillow不需要额外引入数据库或模板引擎。它的逻辑是先把图片缩放到目标像素尺寸再统计出现频率最高的max_colors种颜色最后把每个像素坐标转换成一条 box-shadow。from PIL import Image from collections import Counter def to_css(src: str, width: int, height: int, max_colors: int 48) - str: im Image.open(src).convert(RGB).resize((width, height), Image.LANCZOS) px im.load() # 用出现频率最高的 max_colors 种颜色近似整张图 palette [c for c, _ in Counter( px[x, y] for x in range(width) for y in range(height) ).most_common(max_colors)] def nearest(color): return min( palette, keylambda p: (p[0] - color[0]) ** 2 (p[1] - color[1]) ** 2 (p[2] - color[2]) ** 2, ) first nearest(px[0, 0]) shadows [] # 从第二行第二列开始生成元素自身背景作为左上角像素 for y in range(height): for x in range(width): if x 0 and y 0: continue r, g, b nearest(px[x, y]) shadows.append(f{x}px {y}px #{r:02x}{g:02x}{b:02x}) css f.mona {{\n width: 1px; height: 1px;\n css f background: #{first[0]:02x}{first[1]:02x}{first[2]:02x};\n css f box-shadow:\n ,\n .join(shadows) ;\n css }\n return css if __name__ __main__: print(to_css(mona.jpg, 80, 52, 48))这个脚本的关键点有三个第一resize((width, height), Image.LANCZOS)负责降采样LANCZOS 在缩小图片时能保留较多边缘信息适合人物画像第二Counter选出高频颜色作为调色板相当于把几百种肤色收敛成几十种第三nearest()用 RGB 欧氏距离找最近颜色执行简单但对暗部细节不敏感如果需要更细的肤色分层可以换成中位切分算法。生成的 CSS 直接输出元素尺寸是 1×1 像素。直接放在页面上根本看不清需要放大.mona { transform: scale(10); transform-origin: top left; image-rendering: pixelated; }transform: scale(10)会把元素和它全部的 box-shadow 一起放大所以像素坐标也乘以 10image-rendering: pixelated禁止浏览器对缩放后的像素做平滑插值否则边缘会出现一层模糊的灰边。2.3 代码里最关键的部分box-shadow 逐像素命中和 background 定位一个典型输出长这样.mona { width: 1px; height: 1px; background: #d8c5a3; /* 原图左上角第一个像素 */ box-shadow: 1px 0px #d1bd99, 2px 0px #c4ab82, 0px 1px #cbb290, /* 后面是几千条坐标和颜色 */; }理解这段代码的关键在于 box-shadow 的四个长度参数和颜色参数像素画取值作用offset-x像素横坐标相对元素左上角向右偏移offset-y像素纵坐标相对元素左上角向下偏移blur-radius0必须为 0否则色块会向四周扩散spread-radius不写默认 0不能用来扩大像素块color#rrggbb当前像素颜色元素自身背景色承担原图(0, 0)那个像素因为如果写成0px 0px #d8c5a3会和背景重叠多出一次不必要的绘制。后面每一条阴影都是一个独立像素点浏览器把它们依次绘制在元素背面。有人会问为什么不用多个background层加background-position那种方式对每一层都需要一个linear-gradient或单像素背景图层颜色一多CSS 文本会迅速膨胀。box-shadow的优势在于它是一条声明里的无数个逗号分隔项浏览器可以用数组方式一次性解析结构上更接近“点阵数据”。3. 让画像不掉色、不卡顿颜色合并、calc() 定位与 layer 分块3.1 颜色量化用最近邻或中位切分减少 CSS 体积原尺寸的蒙娜丽莎可能有几万种颜色但一张 80×52 的像素画根本不需要这么多。上面脚本里的max_colors就是调色板容量。调得越小每条阴影的颜色越集中在几个固定值上文本重复率越高zip 压缩后越小调得越大肤色过渡越自然但 CSS 原始体积会增长。一个容易被忽略的事实是zip 压缩包大小和浏览器实际解析的 CSS 文本大小不是一回事。zip 会用 deflate 压缩重复的#d8c5a3这类字符串所以压缩包可能只有几十 KB但浏览器解析时拿到的是完整解压后的样式表。你不能只看 zip 源码体积就判断页面性能要看生成的.css解压后有多大。如果觉得最近邻算法在肤色上颗粒感太重常见替代是中位切分把 RGB 颜色空间递归切分让每个分区包含大致相同的像素数量然后取均值作为调色板颜色。它的效果更接近原图但脚本会复杂一些。我自己做这套流程时max_colors一般取 48 到 64脸部明暗够体积也在可控范围内。3.2 定位参数box-shadow 的偏移量怎么写才对box-shadow 的偏移量原点是元素自身左上角不是外层容器。也就是说如果你把.mona放在一个.stage里面box-shadow的1px 0px永远表示“相对这个 1×1 元素向右 1 像素”不会因为父级位移而改变它内部的坐标含义。但是当你要把整幅图拆成多个 layer 时比如左半幅和右半幅就需要用calc()来摆第二块的位置。下面的结构把图按像素列切成左右两部分div classstage div classlayer-left/div div classlayer-right/div /div.stage { position: relative; width: 1px; height: 1px; transform: scale(10); transform-origin: top left; } .layer-left, .layer-right { position: absolute; width: 1px; height: 1px; top: 0; background: #d8c5a3; } .layer-left { left: 0; box-shadow: /* 只生成原图第 0 到 39 列的阴影 */; } .layer-right { left: calc(40 * 1px); box-shadow: /* 只生成原图第 40 到 79 列的阴影坐标从 0 重新计 */; }这里calc(40 * 1px)等于40px但因为stage整体被scale(10)放大它最后在屏幕上表现为第 40 列对应的位置。右侧 layer 内部的 box-shadow 坐标要从 0 开始计不能直接把原图的绝对坐标写进去否则它会重复绘制左侧内容。需要注意不要在每一条 box-shadow 里写calc()。几千条calc(10px 2px)会让解析器做大量重复计算生成脚本也难维护。calc()只适合用在 layer 级别的定位上。3.3 分块与性能8000 像素以上必须拆成多层单个元素承载过多 box-shadow最明显的表现是首屏样式解析慢滚动或者缩放时 GPU 合成压力大。具体阈值和浏览器版本有关但可以给一个保守经验总阴影数建议分块数原因3000 以下1单元素完全可以承载3000 - 80001 或 2不带动画时可以单块带动画建议分 28000 以上4 或 8单层绘制面积太大动画掉帧风险高分块不只是均匀切列还要注意每一块内的像素数量尽量均衡。蒙娜丽莎画面中央细节多如果简单按“等宽列”切中间块可能比其他块多出 30% 的阴影块与块之间的负担就不均衡。更稳的做法是按像素总和切先把所有像素按列划分统计每列阴影数量再做等量切分。拆成多块后浏览器会把每个position: absolute且带独立 box-shadow 的层视为独立绘制单元这就是 layer 名称的由来。配合transform: scale(10)的整体缩放四个 layer 分别定位视觉上是一幅整画但重绘时只需刷新变化区域。4. 给蒙娜丽莎加动画CSS3 动画延迟、执行次数与完成后状态保持4.1 把眼睛和嘴角拆成独立图层如果直接对整张蒙娜丽莎的 box-shadow 列表做animation浏览器不会聪明到逐条插值几千个阴影。它没有统一的“颜色起点”和“终点”即便能执行性能也会惨不忍睹。所以需要把眼睛、嘴角这些要动的部分从大阴影列表里抠出来单独做成小图层。生成脚本里可以加一个跳过的区域列表def in_regions(x, y, regions): return any(rx0 x rx1 and ry0 y ry1 for rx0, ry0, rx1, ry1 in regions) # 比如左眼占用像素 (15, 20) 到 (17, 22) regions [(15, 20, 17, 22)]然后在生成shadows的循环里跳过这些区域同时把区域内的像素颜色输出成独立小块的 background。HTML 结构可以这样组织div classstage div classmona/div div classeye-left/div div classmouth/div /div眼睛图层用 CSS 画出一个小小的半圆点再给它的transform添加缩放动画.eye-left { position: absolute; left: 15px; top: 20px; width: 2px; height: 2px; background: radial-gradient( circle at 1px 1px, #2b1c12 0.5px, transparent 1px ); transform-origin: top; animation: blink 5s ease-in-out infinite alternate; } keyframes blink { from { transform: scaleY(0.1); } to { transform: scaleY(1); } }因为.stage已经scale(10)这个 2×2 的图层在屏幕上显示为 20×20 像素视觉上刚好盖住原来几个 box-shadow 像素点。transform-origin: top很关键它让眼睛从顶部向下闭合而不是从中心向两边缩成一条线。4.2 animation-delay让不同区域“接力”出现一个常见的加载动效是页面打开后蒙娜丽莎先整体显示然后眼睛、嘴角依次轻微出现。这里会用到 css3 动画延迟和完成后状态的保持。.layer { opacity: 0; animation: reveal 1.2s cubic-bezier(0.4, 0, 0.2, 1) both; } .eye-left { animation-delay: 0.3s; } .eye-right { animation-delay: 0.6s; } keyframes reveal { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }animation-fill-mode: both在这里是关键。它分成两个阶段动画延迟期间元素先应用from状态也就是opacity: 0画面不会闪现原始样式动画结束后元素保持to状态也就是完成后的状态。如果漏了both延迟期间opacity: 0仍然有效因为声明里写了但如果你依赖动画自身把透明度改到 1结束后没有forwards元素会跳回声明里的透明度。所以要稳妥就写both。fill-mode 值延迟期间结束后适用场景none使用声明值回到声明值循环动画backwards使用第一帧回到声明值延迟入场但不保留结果forwards使用声明值保持最后一帧一次性入场结束后留在终点both使用第一帧保持最后一帧带延迟的一次性入场还要注意animation-delay只作用于动画第一次启动不会在每次循环之间插入等待。想让两个元素在循环中不断错开需要给每个元素设置不同的 duration或者用多个关键帧分组。4.3 完成后状态的保持和逆向播放forwards、backwards、alternate给蒙娜丽莎的嘴角加一个持续微笑的动画可以用alternate实现 css3 动画执行次数和逆向播放的配合.mouth { transform-origin: center bottom; animation: smile 3s ease-in-out infinite alternate both; } keyframes smile { from { transform: scaleY(0.96) rotate(0.5deg); } to { transform: scaleY(1.04) rotate(-0.5deg); } }animation-direction: alternate会让动画在奇数循环正向播放偶数循环反向播放。这里infinite表示执行次数无限于是嘴角在“微微上翘”和“回复原位”之间往复。如果只想要完整的“正向一次、反向一次”把infinite改成2正好播完两个半场。动画简写属性里有两个时间值顺序不能乱。第一个是animation-duration第二个是animation-delay。例如animation: smile 3s 1s ease-in-out 2 alternate both;意思是 duration 为 3 秒延迟 1 秒执行 2 次交替方向且延迟和结束时都保持关键帧状态。如果你看到动画没有延迟多半是把时间值写到了 delay 的位置也就是说 duration 被当成 1sdelay 变成 3s。5. 验证与交付如何确认纯 CSS3 画像与 zip 源码在别人机器上一致5.1 用像素比对脚本验证还原度生成完 CSS 后不能只在本地浏览器里“看着像”。最稳的验证方法是用无头浏览器截一张元素图再和原图做像素比对。这里用 Playwright 做截图先安装依赖python -m pip install playwright playwright install chromium截图脚本如下import asyncio from playwright.async_api import async_playwright async def shoot(page_path, shot_path): async with async_playwright() as p: browser await p.chromium.launch() page await browser.new_page(viewport{width: 1200, height: 800}) await page.goto(file:// page_path) # 关掉全部动画保证截到静止状态 await page.add_style_tag(content *, *::before, *::after { animation: none !important; transition: none !important; } ) await page.locator(.stage).screenshot(pathshot_path) await browser.close() asyncio.run(shoot(index.html, shot.png))拿到shot.png后用 Pillow 与原图统一尺寸再算差异区域from PIL import Image, ImageChops a Image.open(shot.png).convert(RGB) b Image.open(mona.jpg).convert(RGB).resize(a.size) diff ImageChops.difference(a, b) print(diff.getbbox())getbbox()返回差异区域的边界框。对纯 CSS3 画像完全一致很难因为调色板和放大插值会让边缘有一圈过渡色。通常只要差异边界框相对整张图面积小于 5%就可以认为生成链路正确。如果差异出现在五官区域优先检查regions是否覆盖了被拆走的动画图层。5.2 zip 源码包里应包含哪些文件交付时压缩包内不需要把原画高清图塞进去但建议保留生成脚本和参数记录。通常我会放这几个文件文件作用index.html页面入口包含 stage 和所有动画 layerstyle.css最终 box-shadow 与动画样式build.py把新的 PNG 转成 CSS 的生成脚本README.txt记录尺寸、max_colors、区域坐标和浏览器验证方式压缩时直接保留目录结构不要在外面再套一层同名文件夹解压的人经常会遇到“解压后又多了一层”。打包命令可以写成zip -9 -r 纯CSS3绘制的蒙娜丽莎画像源码.zip index.html style.css build.py README.txt-9表示最大压缩级别box-shadow 里大量重复的颜色文本在 deflate 下收益明显。如果别人下载后解压出现error read zip archive先不要怀疑源码先用unzip -t 文件.zip检查完整性这个报错绝大多数情况是传输过程丢字节不是浏览器兼容问题。网上常看到“zip 压缩包密码破解工具”之类的说法那只是针对加密 zip 的。源码包没必要加密加了密码反而会让协同编辑多一道门槛。源码交付的重点是“可校验”不是“不可读”。5.3 一个值得保留的小技巧用 CSS 自定义属性统一调色板动画图层和背景色可以使用:root里的自定义属性统一管理:root { --skin-high: #e2cd9f; --skin-mid: #c9b48a; --hair: #241305; --bg: #2f241a; } .eye-left { background: radial-gradient( circle at 1px 1px, var(--hair) 0.5px, transparent 1px ); } .body { background: var(--skin-mid); }这个技巧只推荐用在手写部分不要试图把整个 box-shadow 列表里的颜色都替换成变量。变量名比十六进制字符串更长几千条阴影里的重复变量名反而会增加解析和传输成本。正确的边界是大规模点阵直接写颜色字面量动画图层和背景色用变量。在 README.txt 里记下width80, height52, max_colors48, regions[(15,20,17,22)]下次拿到新的蒙娜丽莎裁切图直接运行python build.py就能重新生成整套 CSS。本文还有配套的精品资源点击获取