极简纯 CSS 实现模糊图片占位符 (Minimal CSS-only Blurry Image Placeholders)
[中文正文内容]
30 Mar 2025 · 22 min read
这里介绍一种 CSS 技术,可以生成模糊的图片占位符 (LQIPs),而无需增加代码的复杂性 —— 只需要一个自定义属性!
<img src="…" style="--lqip:192900">
上面的自定义属性会生成如下的图片效果:
尝试更改属性的值 (警告:闪烁)
诚然,与其他领先的解决方案相比,这是一种 非常模糊 的占位符。但重点在于,它非常简洁且无侵入性!不需要包装元素或包含长数据字符串的属性,甚至完全不需要 JavaScript。
针对 RSS 阅读器 / ‘Reader’ 模式客户端的说明:这篇文章大量使用了基于 CSS 的图像。您的客户端可能不支持它。
示例图片
[ 切换图片 ](https://leanrada.com/notes/css-only-lqip/javascript:\(\(\)={let s;(s=document.getElementById('lqip-debug'))?s.remove():((s=document.createElement('style')).innerHTML=%60[style*='--my-lqip:']:not(:hover){object-position:calc(infinity*1px)!important}%60,s.id='lqip-debug',document.head.append(s))})()>) 查看 LQIP 画廊获取示例!
LQIP 方法概览
已经有很多不同的技术来实现 LQIPs (low quality image placeholders),例如极低分辨率的 WebP 或 JPEG (beheaded JPEGs 甚至), 优化的 SVG 形状占位符 (SQIP), 以及直接应用离散余弦变换 (BlurHash). 别忘了经典的 progressive JPEGs 和 interlaced PNGs!
Canva 和 Pinterest 使用纯色占位符。
另一方面,我们也有一些低技术含量的解决方案,比如简单地用图片平均颜色填充。
纯内联 CSS 解决方案的优势在于可以立即渲染 —— 甚至使用 background-image: url(…a data URL) 也是可以的!
Gradify 生成线性渐变,可以粗略地近似整个图像。
纯 CSS 方法的主要缺点是,通常会在代码中添加冗长的内联样式或令人讨厌的 data URLs。 我的手工编码网站,没有构建步骤,与这种方法尤其不兼容!
<!-- typical gradify css -->
<img width="200" height="150" style="
background: linear-gradient(45deg, #f4a261, transparent),
linear-gradient(-45deg, #e76f51, transparent),
linear-gradient(90deg, #8ab17d, transparent),
linear-gradient(0deg, #d62828, #023047);
">
BlurHash 是一种通过将图像数据压缩成 简短的 base-83 字符串 来最小化代码量的解决方案,但解码和渲染这些数据需要额外的 JS...
<!-- a blurhash markup -->
<img width="200" height="150" src="…"
data-blurhash="LEHV6nWB2yk8pyo0adR*.7kCMdnj">
BlurHash 示例
是否可以用 CSS 解码 blur hash 呢?
使用纯 CSS 解码
与 BlurHash 不同,我们不能使用字符串编码,因为 CSS 中几乎没有字符串操作函数 (2025),所以字符串是不行的。
最后,我提出了自己的哈希/编码方式,而 integer 类型是其最佳载体。
通常,将数据编码为单个整数的方法是使用 bit packing,将多个数字作为位打包到一个整数中。 令人惊讶的是,我们可以在纯 CSS 中解压它们!
要解压位,你所需要的只是位移和位掩码。 位移 可以通过除法和向下取整操作来完成 —— calc(x / y) 和 round(down,n) —— 而 位掩码 可以通过取模函数 mod(a,b) 来完成。
* {
/* Example packed int: */
/* 0b11_00_001_101 */
--packed-int: 781;
--bits-9-10: mod(round(down, calc(var(--packed-int) / 256)), 4); /* 3 */
--bits-7-8: mod(round(down, calc(var(--packed-int) / 64)), 4); /* 0 */
--bits-4-6: mod(round(down, calc(var(--packed-int) / 8)), 8); /* 1 */
--bits-0-3: mod(var(--packed-int), 8); /* 5 */
}
当然,我们也可以使用 pow(2,n) 而不是硬编码 2 的幂。
因此,单个 CSS 整数值 将成为我的 CSS-only blobhash(我现在就这么称呼它)的“哈希”的编码。 但是,我们可以在单个 CSS int 中打包多少信息呢?
旁注:CSS 值的限制
规范中没有说明 int 值的允许范围,这让我对我的恶作剧能否成功捏了一把汗,这取决于浏览器厂商的心情。
从我的实验来看,在自定义属性中,你似乎只能使用 -999,999 到 999,999 范围内的整数,超过这个范围就会失去精度。 刚超出这个限制,我们就会开始得到四舍五入到十位的数字 —— 1,234,56~~7~~ 变成 1,234,560。 这很奇怪(精度是以小数位计算的!?),但我敢打赌这是由于历史原因,可能和 Internet Explorer 有关。
无论如何,在 [-999999, 999999] 的范围内,有 1,999,999 个值。 这意味着,使用单个整数哈希,几乎可以描述两百万个 LQIP 配置。 为了便于计算,我将其减少到最接近的 2 的幂,即 220。
220 = 1,048,576 < 1,999,999 < 2,097,152 = 221
简而言之,我有 20 位的的信息来编码基于 CSS 的 LQIP 哈希。
为什么称它为“哈希”? 因为它是一个从任意大小的数据到固定大小的值的映射。 在这种情况下,有无限数量的任意大小的图像,但只有 1,999,999 个可能的哈希值。
方案
只有 20 位,LQIP 图像必须是完整图像的非常简化的版本。 我最终选择了这个方案:单个底色 + 6 个亮度分量,以 3×2 的网格覆盖在底色之上。 这是一个相当极端的 chroma subsampling 版本。

总共有 9 个数字 需要打包到 20 位整数中:
底色 编码在 较低的 8 位 中,使用 Oklab colour space。 亮度使用 2 位,a 和 b 坐标各使用 3 位。 我发现 Oklab 可以给出主观上平衡的结果,但 RGB 应该也能正常工作。
6 个灰度分量 编码在 较高的 12 位 中 —— 每个分量 2 位。
创建了一个离线脚本,用于将任何给定图像压缩成这种整数格式。 该脚本非常简单:获取平均或主导颜色 —— 有很多库可以做到这一点 —— 然后将图像缩小到 3×2 像素并获取灰度值。 这是我的脚本。
我甚至尝试使用 genetic algorithm 来优化 LQIP 位,但适应度函数很难确定。 最终,我需要一个离线 CSS 渲染器才能准确地完成这项工作。 也许未来的迭代可以使用一些 headless Chrome 解决方案来自动比较 LQIP 的真实渲染效果与源图像。
编码完成后,将其设置为目标元素中 style 属性的 --lqip 的值。 然后可以在 CSS 中解码它。 这是我用于解码的实际代码:
[style*="--lqip:"] {
--lqip-ca: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 18))), 4);
--lqip-cb: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 16))), 4);
--lqip-cc: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 14))), 4);
--lqip-cd: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 12))), 4);
--lqip-ce: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 10))), 4);
--lqip-cf: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 8))), 4);
--lqip-ll: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 6))), 4);
--lqip-aaa: mod(round(down, calc((var(--lqip) + pow(2, 19)) / pow(2, 3))), 8);
--lqip-bbb: mod(calc(var(--lqip) + pow(2, 19)), 8);
在渲染解码值之前,需要将原始数字数据值转换为 CSS 颜色。 这非常简单,只需将一些线性插值应用到颜色构造函数中。
/* continued */
--lqip-ca-clr: hsl(0 0% calc(var(--lqip-ca) / 3 * 100%));
--lqip-cb-clr: hsl(0 0% calc(var(--lqip-cb) / 3 * 100%));
--lqip-cc-clr: hsl(0 0% calc(var(--lqip-cc) / 3 * 100%));
--lqip-cd-clr: hsl(0 0% calc(var(--lqip-cd) / 3 * 100%));
--lqip-ce-clr: hsl(0 0% calc(var(--lqip-ce) / 3 * 100%));
--lqip-cf-clr: hsl(0 0% calc(var(--lqip-cf) / 3 * 100%));
--lqip-base-clr: oklab(
calc(var(--lqip-ll) / 3 * 0.6 + 0.2)
calc(var(--lqip-aaa) / 8 * 0.7 - 0.35)
calc((var(--lqip-bbb) + 1) / 8 * 0.7 - 0.35)
);
}
又到了演示时间! 尝试使用不同的 --lqip 值来解码, 你可以在这里看到每个分量变量如何映射到 LQIP 图像。 例如,cb 值对应于顶部中间区域的相对亮度。 有趣的事实:上面的预览内容是用纯 CSS 实现的!
渲染
最后,渲染 LQIP。 我使用多个 radial gradients 来渲染灰度分量,并在底部使用纯色底色。
[style*="--lqip:"] {
background-image:
radial-gradient(50% 75% at 16.67% 25%, var(--lqip-ca-clr), transparent),
radial-gradient(50% 75% at 50% 25%, var(--lqip-cb-clr), transparent),
radial-gradient(50% 75% at 83.33% 25%, var(--lqip-cc-clr), transparent),
radial-gradient(50% 75% at 16.67% 75%, var(--lqip-cd-clr), transparent),
radial-gradient(50% 75% at 50% 75%, var(--lqip-ce-clr), transparent),
radial-gradient(50% 75% at 83.33% 75%, var(--lqip-cf-clr), transparent),
linear-gradient(0deg, var(--lqip-base-clr), var(--lqip-base-clr));
}
上面是完整渲染器的简化版本,仅用于说明目的。 真正的渲染器具有双层、平滑的渐变衰减和混合模式。
正如你可能期望的那样,径向渐变以 3×2 的网格排列。 你可以在这个交互式解构视图中看到它!
LQIP 解构器! 使用此滑块显示各个层! 更改 --lqip 值,
这些径向渐变是基于 CSS 的 LQIP 的核心。 渐变的位置和半径是一个重要的细节,它将决定这些渐变在多大程度上可以近似真实图像。 除此之外,另一个要求是,这些单独的径向渐变在组合在一起时必须是无缝的。
我实现了平滑的渐变衰减,以使最终结果看起来无缝。 为了使渐变额外平滑,需要特别注意,因此让我们深入了解一下…
使用径向渐变近似双线性插值
默认情况下,径向渐变使用线性插值。 插值是指它如何映射从起始颜色到结束颜色之间的中间颜色。 而线性插值,是最基本的插值……
CSS 径向渐变采用线性插值
效果不好。 它给了我们这些硬边(上面突出显示)。 你几乎可以看到每个径向渐变的椭圆边缘及其中心。
在真实的栅格图像中,在放大低分辨率图像时,我们至少会使用 双线性插值。 Bicubic 插值甚至更好。
在 CSS 径向渐变阵列中模拟双线性插值的平滑度的一种方法是使用 “二次缓动” 来控制不透明度的渐变。
这意味着渐变的不透明度衰减在中心和边缘附近会更平滑。 每个渐变都会获得羽化边缘,从而使整体合成图像更平滑。
CSS radial-gradients: Quadratic interpolation (touch to see edges) CSS radial-gradients: Linear interpolation (touch to see edges)
Image: Bilinear interpolation
Image: Bicubic interpolation
Image: Your browser’s native interpolation
Image: No interpolation
但是,CSS 渐变 目前还不支持不透明度的非线性插值(不要与颜色空间插值混淆,浏览器支持颜色空间插值!)。 目前的解决方案是在渐变中添加更多点,以获得基于二次公式的平滑不透明度曲线。
radial-gradient(
<position>,
rgb(82 190 240 / 100%) 0%,
rgb(82 190 204 / 98%) 10%,
rgb(82 190 204 / 92%) 20%,
rgb(82 190 204 / 82%) 30%,
rgb(82 190 204 / 68%) 40%,
rgb(82 190 204 / 32%) 60%,
rgb(82 190 204 / 18%) 70%,
rgb(82 190 204 / 8%) 80%,
rgb(82 190 204 / 2%) 90%,
transparent 100%
)
二次插值基于两条二次曲线(抛物线),一条用于渐变的每一半 - 一条向上,另一条向下。
二次缓动将相邻的径向渐变混合在一起,模仿平滑的双线性(甚至双三次)插值。 这几乎就像一个假的模糊滤镜,从而实现了这个 BlurHash 替代方案的“模糊”部分。
查看图库以直接与 BlurHash 进行比较。
[ 切换图片 ](https://leanrada.com/notes/css-only-lqip/javascript:\(\(\)={let s;(s=document.getElementById('lqip-debug'))?s.remove():((s=document.createElement('style')).innerHTML=%60[style*='--my-lqip:']:not(:hover){object-position:calc(infinity*1px)!important}%60,s.id='lqip-debug',document.head.append(s))})()>)
附录:考虑过的替代方案
四种颜色代替单色预览
四种 5 位颜色,其中每个 R 为 2 位,G 为 2 位,而 B 仅为零或一。
四种颜色将映射到图像框的四个角,渲染为径向渐变
这是我的第一次尝试,并且我已经摆弄了一段时间,但是正确地混合四种颜色需要适当的双线性插值,可能还需要着色器。 仅仅分层渐变会导致浑浊(就像混合太多的水彩颜料一样),并且没有 CSS 混合模式可以修复它。 所以我放弃了它,并转向单色方法。
单一纯色
这是我之前在这个网站上使用的。 它简单而有效。 一个干净的代码方法仍然可以使用自定义 --lqip 变量:
<img src="…" style="--lqip:#9bc28e">
<style>
/* we save some bytes by ‘aliasing’ this property */
* { background-color: var(--lqip) }
</style>
HTML 属性代替 CSS 自定义属性
我们很快就可以使用 HTML 属性来控制 CSS 了! 这是未来 LQIP 代码的样子:
<img src="…" lqip="192900">
等待 attr() Level 5。 它更简洁,并且代码中的标点符号更少(谁想出了 CSS 变量的双破折号呢?)。 然后可以使用 attr(lqip type(<number>)) 而不是 var(--lqip) 在 CSS 中引用该值。
为了更加安全,可以在属性名称中添加 data- 前缀。
迫不及待地希望它得到广泛采用。 我也希望它用于我的 TAC components。