DEV Community

Joe Lin for BeGoodTool.com

Posted on

Why a precise image color picker needs two canvases (one to read pixels, one to draw the loupe)

A while back I thought an image color picker would be a tiny weekend feature: upload an image, hover it, read a pixel, done.

What I didn't really appreciate until I built it is that the browser gives you display-space coordinates, while getImageData() wants real image pixels. And once you add a magnifier that has to feel trustworthy, you end up with a second canvas too.

The real job is mapping cursor space back to image space

The displayed <img> is responsive, but the actual color has to come from the image's natural pixel grid. So on load, the component draws the uploaded image onto a hidden canvas at naturalWidth × naturalHeight, then keeps a scale based on the rendered width:

const scale = computed(() =>
  naturalWidth.value && displayWidth.value
    ? displayWidth.value / naturalWidth.value
    : 1,
);

const getPixelAtClient = (clientX, clientY) => {
  const rect = stageWrapRef.value.getBoundingClientRect();
  let dx = clamp(clientX - rect.left, 0, rect.width - 1);
  let dy = clamp(clientY - rect.top, 0, rect.height - 1);
  const s = scale.value || 1;
  const natX = clamp(Math.floor(dx / s), 0, naturalWidth.value - 1);
  const natY = clamp(Math.floor(dy / s), 0, naturalHeight.value - 1);

  const ctx = mainCanvasEl.value.getContext("2d");
  const data = ctx.getImageData(natX, natY, 1, 1).data;
  return { r: data[0], g: data[1], b: data[2] };
};
Enter fullscreen mode Exit fullscreen mode

That Math.floor(dx / s) part is the whole trick. Without it, you'd be sampling CSS pixels, not image pixels, and the picker would drift as soon as the image got resized.

There's another nice detail in the load path: the hidden canvas is created with willReadFrequently: true. That's exactly the right hint here, because this tool calls getImageData() over and over while the pointer moves.

The loupe isn't CSS zoom — it's a second canvas with smoothing disabled

The magnifier looked fuzzy until I stopped treating it like a visual effect and treated it like another rendering surface.

Instead of scaling the DOM image, the component copies a tiny source square around the target pixel onto a dedicated magnifier canvas:

const MAG_SIZE = 160;
const SRC_SIZE = 15;

const drawMagnifier = (natX, natY) => {
  const w = Math.min(SRC_SIZE, naturalWidth.value);
  const h = Math.min(SRC_SIZE, naturalHeight.value);
  const sx = clamp(natX - Math.floor(w / 2), 0, Math.max(0, naturalWidth.value - w));
  const sy = clamp(natY - Math.floor(h / 2), 0, Math.max(0, naturalHeight.value - h));

  const ctx = magnifierCanvasEl.value.getContext("2d");
  ctx.imageSmoothingEnabled = false;
  ctx.clearRect(0, 0, MAG_SIZE, MAG_SIZE);
  ctx.drawImage(mainCanvasEl.value, sx, sy, w, h, 0, 0, MAG_SIZE, MAG_SIZE);
Enter fullscreen mode Exit fullscreen mode

imageSmoothingEnabled = false is what makes the loupe feel honest. You're seeing enlarged blocks, not blurred interpolation.

Then it draws guide lines and a red rectangle around the center pixel. I especially like that the crosshair leaves the center cell empty before continuing, so the indicator doesn't literally paint over the color you're trying to inspect.

Every picked pixel gets normalized into HEX, RGB, and HSL immediately

Once the picker has r, g, and b, it immediately builds all three output formats:

const toHex2 = (n) => n.toString(16).padStart(2, "0").toUpperCase();
const rgbToHex = (r, g, b) => `#${toHex2(r)}${toHex2(g)}${toHex2(b)}`;

const buildColorInfo = (r, g, b) => {
  const hsl = rgbToHslParts(r, g, b);
  return {
    r,
    g,
    b,
    hex: rgbToHex(r, g, b),
    rgb: `rgb(${r}, ${g}, ${b})`,
    hsl: `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`,
  };
};
Enter fullscreen mode Exit fullscreen mode

That sounds obvious, but it changes the UX a lot. The first click commits the color to the palette and copies HEX by default, while the side panel and saved swatches still let you copy RGB or HSL later. So the app stores one normalized color object instead of recomputing formats in three different places.

The HSL conversion is done manually too, with the usual max/min/delta branch logic. That's worth doing in the component because you want deterministic output formatting, not whatever a browser API happens to expose.

The gotchas are mostly about pixels not meaning what you think they mean

A few things here are messier than the happy-path demo suggests:

  • Compressed images lie a little. JPG artifacts and anti-aliased edges mean the pixel under the cursor may not match the flat brand color you thought you were sampling.
  • Alpha is currently ignored. getImageData() returns RGBA, but this component only keeps data[0], data[1], and data[2]. So with transparent PNGs, you're copying the stored RGB values, not an rgba(...) result that reflects transparency.
  • The screen picker is browser-dependent. The extra EyeDropper button only appears when window.EyeDropper exists, which effectively means Chromium-family browsers.
  • Canvas security rules still exist. This tool uses local uploads, which avoids most trouble, but the getImageData() call is still wrapped in try/catch for a reason. If you adapt the same pattern to cross-origin images, a tainted canvas will block pixel reads.

I cleaned this up into a small free tool while I was building it: Image Color Picker.


Available in other languages

Top comments (0)