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] };
};
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);
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}%)`,
};
};
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 keepsdata[0],data[1], anddata[2]. So with transparent PNGs, you're copying the stored RGB values, not anrgba(...)result that reflects transparency. -
The screen picker is browser-dependent. The extra
EyeDropperbutton only appears whenwindow.EyeDropperexists, 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 intry/catchfor 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
- 圖片精確色碼擷取器 — 繁體中文
- 图片精确色码提取器 — 简体中文
- Image Color Picker — English
- 画像カラーピッカー — 日本語
- 이미지 색상 추출기 — 한국어
- Sélecteur de couleur d'image — Français
- Пипетка цвета из изображения — Русский
- Bildfarbwähler — Deutsch
- Pengambil Warna Gambar — Bahasa Indonesia
- Selector de color de imagen — Español
- Công cụ lấy mã màu từ ảnh — Tiếng Việt
- เครื่องมือดึงสีจากภาพ — ไทย
- Pipeta kolorów ze zdjęcia — Polski
- Görsel Renk Seçici — Türkçe
- Selettore di colore da immagine — Italiano
- Seletor de cor de imagem — Português
- Afbeelding kleurenkiezer — Nederlands
- Піпетка кольору із зображення — Українська
Top comments (0)