DEV Community

M620
M620

Posted on

Making a screen's background follow its cover image in React Native Web (no library)

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

Luma clone demo

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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%)`;
}
Enter fullscreen mode Exit fullscreen mode

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]}>
Enter fullscreen mode Exit fullscreen mode

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 its uri (or httpServerLocation + 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)