LQIP CSS placeholders

Copy Markdown View Source

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:

PixelCSS variableRole
top-left--lqip-c0background color
center--lqip-c1first radial gradient
bottom-right--lqip-c2second 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).

Kamchatka
Kamchatka
Placeholder #bcceca32
Mongolia
Mongolia
Placeholder #9bd35643
Lamborghini
Lamborghini
Placeholder #fff0cb6e
Singapore
Singapore
Placeholder #3688881a
Hong Kong
Hong Kong
Placeholder #578acc9a
Sydney Opera House (B&W)
Sydney Opera House (B&W)
Placeholder #ddccce4c