Image.Lqip.Css encodes an LQIP image as a CSS value: a single 32-bit number,
packed into an RGBA hex code like #a1b2c3d4, that a static piece of CSS
expands into a blurred gradient placeholder.
The technique was created by Freek Zijlmans (documented in this article),
and the reference implementation lives at https://github.com/frzi/lqip-css.
Unlike Image.Blurhash, these placeholders are decoded entirely by the
browser's CSS engine.
How it works
Image.Lqip.Css.encode/1 resizes the image to a 3x3 thumbnail and samples three
pixels:
| Pixel | CSS variable | Role |
|---|---|---|
| top-left | --lqip-c0 | background color |
| center | --lqip-c1 | first radial gradient |
| bottom-right | --lqip-c2 | second radial gradient |
Each color is quantized (adjusted for chroma),
and bit-packed into a single 32-bit value, which is rendered as an 8-digit
RGBA hex color, #RRGGBBAA:
| RR | GG | BB | AA |
|0 0 0 0 0 0 0 0|0 0 0 0 0 0 0 0|0 0 0 0 0 0 0 0|0 0 0 0 0 0 0 0|
|R R R R G G G G|B B B R R R R G|G G G B B B R R|R G G G G B B B|
|- - - - = = = = - - -|= = = = - - - - = = =|- - - = = = = - - -|
| color 0 | color 1 | color 2 |
| 11-bit | 11-bit | 10-bit |Generating the value
Compute the value once, and store it alongside the image:
{:ok, image} = Image.open("photo.jpg")
{:ok, hex} = Image.Lqip.Css.encode(image)
#=> {:ok, "#a1b2c3d4"}Then set a CSS variable on the element using inline styles:
<img src="photo.jpg" style="--lqip: #a1b2c3d4" />or as a data attribute, accessed by the stylesheet using the attr() function:
<img src="photo.jpg" data-lqip="#a1b2c3d4" />Note that the browser support for attr() is still not quite there,
so for now, the CSS variable is the safest choice.
The CSS
The stylesheet is identical for every image and only needs to be included once. It unpacks the LQIP value into three colors (using relative color syntax) and paints them as two radial gradients over the background color.
This CSS is a verbatim copy of the reference implementation's lqip.css:
[data-lqip] {
--lqip-c: attr(data-lqip type(<color>), white);
}
[style*="--lqip:"] {
--lqip-c: var(--lqip);
}
[style*="--lqip:"],
[data-lqip] {
/*
* | RR | GG | BB | AA |
* |0 0 0 0 0 0 0 0|0 0 0 0 0 0 0 0|0 0 0 0 0 0 0 0|0 0 0 0 0 0 0 0|
* |R R R R G G G G|B B B R R R R G|G G G B B B R R|R G G G G B B B|
* |- - - - = = = = - - -|= = = = - - - - = = =|- - - = = = = - - -|
* | color 0 | color 1 | color 2 |
* | 11-bit | 11-bit | 10-bit |
*/
--lqip-c0: color(
from var(--lqip-c) srgb calc(round(down, r * 255 / pow(2, 4)) / 15)
calc(mod(round(down, r * 255), pow(2, 4)) / 15)
calc(round(down, g * 255 / pow(2, 5)) / 7) / 1
);
--lqip-c1: color(
from var(--lqip-c) srgb calc(mod(round(down, g * 255 / 2), pow(2, 4)) / 15)
calc(
(
(mod(round(down, g * 255), 2) * pow(2, 3)) +
(round(down, b * 255 / pow(2, 5)))
) /
15
)
calc(mod(round(down, b * 255 / pow(2, 2)), pow(2, 3)) / 7) / 1
);
--lqip-c2: color(
from var(--lqip-c) srgb
calc(
(
((mod(round(down, b * 255), pow(2, 2)) * 2)) +
round(down, alpha * 255 / pow(2, 7))
) /
7
)
calc(mod(round(down, alpha * 255 / pow(2, 3)), pow(2, 4)) / 15)
calc(mod(round(down, alpha * 255), pow(2, 3)) / 7) / 1
);
background:
radial-gradient(
150% 75% at 80% 100%,
var(--lqip-c2),
rgb(from var(--lqip-c2) r g b / 98%) 10%,
rgb(from var(--lqip-c2) r g b / 92%) 20%,
rgb(from var(--lqip-c2) r g b / 82%) 30%,
rgb(from var(--lqip-c2) r g b / 68%) 40%,
rgb(from var(--lqip-c2) r g b / 32%) 60%,
rgb(from var(--lqip-c2) r g b / 18%) 70%,
rgb(from var(--lqip-c2) r g b / 8%) 80%,
rgb(from var(--lqip-c2) r g b / 2%) 90%,
transparent
),
radial-gradient(
100% 75% at 40% 50%,
var(--lqip-c1),
rgb(from var(--lqip-c1) r g b / 98%) 10%,
rgb(from var(--lqip-c1) r g b / 92%) 20%,
rgb(from var(--lqip-c1) r g b / 82%) 30%,
rgb(from var(--lqip-c1) r g b / 68%) 40%,
rgb(from var(--lqip-c1) r g b / 32%) 60%,
rgb(from var(--lqip-c1) r g b / 18%) 70%,
rgb(from var(--lqip-c1) r g b / 8%) 80%,
rgb(from var(--lqip-c1) r g b / 2%) 90%,
transparent
),
var(--lqip-c0);
}Chroma-aware packing
Because each channel is packed into only 3 or 4 bits, independent per-channel
rounding can give near-grey colors a visible tint. To reduce that, the encoder
chooses each color's packed value to be the one closest to the source in the
CIELAB space, measured with CIEDE2000 (via Color.Distance.delta_e_2000/3).
This keeps near-greys closer to neutral while still preserving chroma for saturated colors.
Examples
For each image, the original (left) and the LQIP placeholder it produces (right).
#bcceca32
#9bd35643
#fff0cb6e
#3688881a
#578acc9a
#ddccce4c