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.
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.
The props
Section titled “The props”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()).
| Property | What it is |
|---|---|
--live-img | the dominant color (the most common one) |
--live-img-accent | the most vibrant color — a punchy, mid-toned accent (reuses the dominant for a grayscale image) |
--live-img-dark | the darkest color that isn’t black |
--live-img-light | the lightest color that isn’t white |
--live-img-avg | the average (mean of every pixel) — distinct from the dominant (the mode) |
--live-img-temp | the 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%); }How it stays cheap
Section titled “How it stays cheap”The whole palette is found by downsampling, not by scanning every pixel — and every swatch comes from a single pass:
createImageBitmap(img, { resizeWidth: 16, resizeHeight: 16 })decodes and shrinks the image off the main thread.- It’s drawn to a 16×16
OffscreenCanvas(no DOM node), and those 256 pixels are read back once. - 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.
The wiring
Section titled “The wiring”<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'])