Open an event in Luma and the whole page takes its colour from the cover image: a red poster gives you a deep red page, a blue one gives you navy. It makes every event feel designed, even when the organiser only uploaded one picture.
My open-source Luma clone does the same thing on the web in about 100 lines, with a <canvas> and no colour library. Here is how.
Live demo: https://migrant620.github.io/luma/ (open any event)
Source (MIT): https://github.com/migrant620/luma-clone
1. Shrink the image to 48 × 48
You don't need every pixel to find the main colour. Drawing the image into a tiny canvas averages it for free and keeps the loop cheap:
const N = 48;
const canvas = document.createElement('canvas');
canvas.width = N;
canvas.height = N;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0, N, N);
const { data } = ctx.getImageData(0, 0, N, N); // 48*48*4 bytes
getImageData throws if the image comes from another origin without CORS headers. Wrap it in try/catch and fall back to your default background.
2. Bucket colours, but weight them
The naive "average all pixels" gives you mud: a red poster with white text averages to a dusty pink. Instead, put each pixel into a coarse bucket (3 bits per channel, 512 buckets) and keep a weighted sum per bucket:
const key = ((r >> 5) << 6) | ((g >> 5) << 3) | (b >> 5);
let w = 0.15 + saturation([r, g, b]); // colourful pixels count more
const v = Math.max(r, g, b) / 255;
if (v < 0.1 || v > 0.95) w *= 0.25; // near-black and near-white count less
Two weights do most of the work:
- Saturation boost. Grey backgrounds and skin tones are common in photos, but they are rarely the colour you want the page to be.
- Black/white penalty. Text, borders and letterboxing would otherwise win on almost every poster.
The bucket with the biggest weight wins, and you use the mean of its pixels, not the bucket's corner value, so the result isn't quantised.
One more rule: if the winner is still greyish (saturation under 0.15) but there is a clearly colourful runner-up with at least a third of its weight, take the runner-up. That handles "mostly white poster with one bold logo" nicely.
3. Turn one colour into a background
A flat fill looks cheap. A vertical gradient from a darker version of the colour at the top to a soft, desaturated version lower down looks like the real thing:
const shade = (c: RGB, f: number): RGB => c.map((v) => v * f) as RGB;
const luma = (c: RGB) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
const desat = (c: RGB, t: number): RGB =>
c.map((v) => v + (luma(c) - v) * t) as RGB;
function background(c: RGB) {
const top = shade(c, 0.5);
const bottom = shade(desat(c, 0.9), 70 / Math.max(luma(c), 1));
return `linear-gradient(180deg, ${css(top)} 0%, ${css(shade(c, 0.3))} 35%, ${css(bottom)} 90%)`;
}
Scaling the bottom colour by 70 / luma normalises brightness. A pale yellow cover and a dark purple one end up with backgrounds of similar lightness, so white text stays readable on both. Layering a soft radial-gradient on top adds a bit of glow; that part is taste.
4. Wire it into React Native Web
React Native styles don't accept CSS gradients, but on web the style object is passed through, so background works if you cast it:
const [bg, setBg] = React.useState<string | null>(null);
React.useEffect(() => {
let alive = true;
extractCoverBackground(ev.cover).then((css) => {
if (alive && css) setBg(css);
});
return () => { alive = false; };
}, [ev.cover]);
<View style={[styles.root, bg ? ({ background: bg } as any) : null]}>
Three details:
-
Cache by URI. Store the promise in a
Map, so going back and forth between events doesn't decode the image again. -
Resolve Expo assets. A
require('./cover.jpg')on web is an object, not a URL. Read itsuri(orhttpServerLocation+name+type) before loading it into an<img>. -
Keep a default. The page renders immediately with a neutral dark background and swaps to the extracted one when it is ready. On native, the function just returns
null.
Try it
Open a few events with different posters and compare the pages: https://migrant620.github.io/luma/
The full file is src/palette.ts. This is one of six app UIs I've rebuilt with React Native + Expo (Airbnb, Threads, Luma, Suno, Tiimo, Structured), all MIT-licensed at https://migrant620.github.io/.
Previous posts in this series: Tiimo's timer ring with one SVG arc · fixing blank Expo web pages on slow networks.

Top comments (0)