Skip to content

Image color

CSS can’t read an image’s pixels, so it can’t tint a card, scrim, or caption to match the artwork inside it. The img-color plugin extracts a small palette from an <img> — the dominant color plus an accent, a darkest, a lightest, an average, and a warm/cool reading — and writes each as a single #rrggbb color you drop straight into var().

It’s the heavier sibling of the img plugin (which does load state and natural size) — kept separate so the pixel-reading cost is opt-in.

Ember
Ocean
Sand

Each card is themed from its art in one 16×16 pass. The footer is painted the --live-img dominant colour, its text flipped black or white from that colour's own lightness; the glow and the strip lead with --live-img-accent, then darkest, lightest and average; and the footer marker rides --live-img-temp (cool ↔ warm). All single hex colours, no separate channels.

Each card above is themed from its own artwork: the caption is painted with the dominant color, the glow is the accent, the chips show the full palette, and the bar reads its temperature — all in CSS, with text flipping black or white based on the background color’s lightness.

Written on the bound element on the live cadence, recomputed whenever the image loads (so a src swap updates them). The pixels are sRGB, so each swatch is a single hex color — no separate r/g/b channel props, because CSS already pulls channels out of a color for you (relative color syntax, color-mix()).

PropertyWhat it is
--live-imgthe dominant color (the most common one)
--live-img-accentthe most vibrant color — a punchy, mid-toned accent (reuses the dominant for a grayscale image)
--live-img-darkthe darkest color that isn’t black
--live-img-lightthe lightest color that isn’t white
--live-img-avgthe average (mean of every pixel) — distinct from the dominant (the mode)
--live-img-tempthe image’s temperature, −1 (cool) … +1 (warm), from its red-vs-blue balance
.card { background: var(--live-img); }
/* need an alpha, a tint, or a single channel? take it from the color */
.tag { background: oklch(from var(--live-img-accent) l c h / 50%); }
.wash { background: color-mix(in oklab, var(--live-img) 20%, white); }
/* contrasting text: flip black/white from the background's own lightness */
.card { color: oklch(from var(--live-img) clamp(0, (0.6 - l) * 100, 1) 0 0); }
/* warmer images lean the UI warmer */
.frame { --hue: calc(220deg - var(--live-img-temp) * 200deg); border-color: hsl(var(--hue) 60% 50%); }

The whole palette is found by downsampling, not by scanning every pixel — and every swatch comes from a single pass:

  1. createImageBitmap(img, { resizeWidth: 16, resizeHeight: 16 }) decodes and shrinks the image off the main thread.
  2. It’s drawn to a 16×16 OffscreenCanvas (no DOM node), and those 256 pixels are read back once.
  3. One loop buckets the colors into a coarse grid and tallies the pixel sum; the dominant is the most-populated bucket, the accent/dark/light fall out of the same buckets by saturation and luminance, the average and temperature come from the running sum, and each is serialized to a hex string. Transparent pixels are skipped.

Both OffscreenCanvas and createImageBitmap are feature-detected, with a plain <canvas> / direct-draw fallback, so it runs wherever canvas does and no-ops where it doesn’t.

<script type="module">import 'prop-for-that/auto'</script>
<!-- the img-color plugin loads itself the first time the attribute is seen -->
<!-- bind the figure so the caption (a sibling of the img) inherits the color -->
<figure data-props-for="img-color">
<img src="/cover.jpg" alt="">
<figcaption></figcaption>
</figure>
import { propsFor, register } from 'prop-for-that'
import { imgColor } from 'prop-for-that/plugins'
register(imgColor)
propsFor(document.querySelector('figure'), ['img-color'])