Reactで、画像から代表的な色を取り出す方法
画像の代表的な色は、ブラウザだけで取り出せます。画像を小さな <canvas> に描き、getImageData() でピクセルの色を読み、似た色ごとに数えて、いちばん多かった色を代表色にします。取り出した色は、画像を載せるカードの背景などに使えます。
色を数える関数と、それを呼ぶカスタムフック useImageColors を作り、代表色をカードの背景にします。平均色を代表色にしない理由、色をまとめる段階の数による結果の違い、別のオリジンの画像で起きるエラーも、検証の結果で示します。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。画像は ImageMagick で作った検証用のもの(2色に塗り分けた PNG、白い背景の PNG、背景が透明な PNG、色がなめらかに変わる模様の JPEG)で、写真は使っていません。Firefox と Safari、スマートフォンのブラウザ、sRGB 以外の色空間を持つ画像は試していません。

平均色を代表色にしない理由
ピクセルの色を足して割る平均色は、計算は簡単ですが、画像のどこにもない色になることがあります。左の6割を水色(#00c8e8)、右の4割をピンク(#d85878)に塗った画像で試したところ、平均色は #569bbb というくすんだ青になりました。代表色として期待するのは、面積の大きい水色のほうです。
そこで、似た色を同じ箱に入れて数え、いちばん多い箱の色を代表色にします。同じ画像では、代表色は #00c8e8(59%)、2番目は #d85878(39%)になりました。残りの2%は #7989a9 です。幅600pxの画像を64pxに縮めると、左から360pxの境目は38.4px目にあたり、1つのピクセルの中に入ります。縮めた画像の上から10行目で境目の前後を読むと、x=38 の1ピクセルだけが #7989a9 で、両側は元の水色とピンクでした。
画像を縮めて描き、色を箱に分けて数える
色を数える処理は、React に依存しない関数にまとめました。getImageColors() は読み込み済みの <img> を受け取り、代表色(dominant)、平均色(average)、多い順の色(palette)を返します。
export type RGB = { r: number; g: number; b: number };
export type Swatch = { color: RGB; ratio: number };
export type ImageColors = { dominant: RGB; average: RGB; palette: Swatch[] };
type Options = {
size?: number; // 縮めたときの長い辺(px)
levels?: number; // R・G・B をそれぞれ何段階にまとめて数えるか
count?: number; // palette に返す色の数
};
// 画像を長い辺が size px 以下になるように縮めて描き、色を数える
export function getImageColors(
img: HTMLImageElement,
{ size = 64, levels = 4, count = 5 }: Options = {},
): ImageColors {
const scale = Math.min(1, size / Math.max(img.naturalWidth, img.naturalHeight));
const w = Math.max(1, Math.round(img.naturalWidth * scale));
const h = Math.max(1, Math.round(img.naturalHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, w, h);
// 別のオリジンの画像を CORS なしで描いたあとは、ここで SecurityError になる
const { data } = ctx.getImageData(0, 0, w, h);
// R・G・B をそれぞれ levels 段階にまとめ、levels³ 個の箱で数える。箱ごとに元の色の合計も持つ
const step = (v: number) => Math.floor((v * levels) / 256); // 0〜255 → 0〜levels-1
const bins = new Map<number, { n: number; r: number; g: number; b: number }>();
let n = 0, r = 0, g = 0, b = 0;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 128) continue; // 透明に近いピクセルは数えない
const pr = data[i], pg = data[i + 1], pb = data[i + 2];
const key = (step(pr) * levels + step(pg)) * levels + step(pb);
const bin = bins.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
bin.n++; bin.r += pr; bin.g += pg; bin.b += pb;
bins.set(key, bin);
n++; r += pr; g += pg; b += pb;
}
if (n === 0) throw new Error('色のあるピクセルがありません(全体が透明です)');
const mean = (s: { n: number; r: number; g: number; b: number }): RGB => ({
r: Math.round(s.r / s.n), g: Math.round(s.g / s.n), b: Math.round(s.b / s.n),
});
const palette = [...bins.values()]
.sort((x, y) => y.n - x.n)
.slice(0, count)
.map((bin) => ({ color: mean(bin), ratio: bin.n / n }));
return { dominant: palette[0].color, average: mean({ n, r, g, b }), palette };
}
drawImage() に描く先の幅と高さを渡すと、画像は縮んで描かれます。getImageData() が返す data は、1ピクセルにつき R・G・B・A の4つの数が並んだ配列なので、4つずつ進めて読みます。
箱の色には、箱の範囲の中央の色ではなく、箱に入ったピクセルの元の色の平均を使います。こうすると、水色だけの部分からは元の #00c8e8 がそのまま返ります。
透明に近いピクセルを飛ばしているのは、背景が透明な PNG のためです。ピンクの円だけを描いた 400×400 の PNG を 64×64 に縮めて読むと、アルファが128未満のピクセルが2,832個(69%)あり、そのうち2,739個は RGBA がすべて0でした。アルファの確認を外して数えると、いちばん多いのは #000000(67%)で、透明な部分が黒として数えられました。アルファを確認する getImageColors() では、代表色は #d85878(100%)です。
色をまとめる段階は、粗いほうが結果が安定する
levels は、R・G・B をそれぞれ何段階にまとめるかを決める値です。細かくすると似た色が別々の箱に分かれ、どの箱にも少しのピクセルしか入らなくなります。色がなめらかに変わる模様の画像(800×533)で、levels を変えて数えた結果が次の図です。

16段階(箱4,096個)では、上位5色がどれも1%で、どの色が1位になるかは偶然に近くなります。同じ色の範囲・同じ乱数の種で作った 4000×3000 の画像では、16段階のとき、縮める大きさによって1位がオレンジ(#e99758)になったり紫(#783777)になったりしました。4段階(箱64個)では、800×533 と 4000×3000 のどちらの画像でも、長い辺を32px・64px・256px のどれにしても、1位は #e19c61 前後のオレンジで、割合も21〜22% でした。
縮める大きさは、処理時間に大きく影響します。4000×3000 の画像で getImageColors() を5回ずつ呼んだ中央値は、次のとおりでした(検証環境の Chromium での値)。
| 長い辺 | 処理時間 | 代表色 |
|---|---|---|
| 64px(既定値) | 0.3 ms | #e19c61(21%) |
| 256px | 2.4 ms | #e29c61(21%) |
| 1024px | 75.5 ms | #e29c61(21%) |
| 4000px(縮めない) | 725.2 ms | #e39d61(20%) |
縮めなくても代表色はほとんど変わらず、時間だけが増えました。代表色を1つ選ぶだけなら、既定の64px で足ります。
画像の読み込みから色を数えるまでをフックにする
React のコンポーネントからは、画像の URL を渡すだけで結果が返るようにします。useImageColors() は、useEffect の中で Image を作って読み込み、decode() で描ける状態になるのを待ってから、getImageColors() を呼びます。
import { useEffect, useState } from 'react';
import { getImageColors, type ImageColors } from './imageColors';
type Result = { src: string; colors: ImageColors } | { src: string; error: string };
export type ImageColorsState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'done'; colors: ImageColors }
| { status: 'error'; error: string };
export function useImageColors(src: string | null): ImageColorsState {
const [result, setResult] = useState<Result | null>(null);
useEffect(() => {
if (!src) return;
let ignore = false;
const img = new Image();
img.crossOrigin = 'anonymous'; // 別のオリジンの画像は CORS で読み込む
img.src = src;
img
.decode()
.then(() => {
if (ignore) return;
try {
setResult({ src, colors: getImageColors(img) });
} catch (e) {
setResult({ src, error: e instanceof Error ? `${e.name}: ${e.message}` : String(e) });
}
})
.catch((e: unknown) => {
if (!ignore) setResult({ src, error: `画像を読み込めません(${e instanceof Error ? e.name : e})` });
});
return () => {
ignore = true; // 結果が届く前に src が変わったら、古い結果は捨てる
};
}, [src]);
if (!src) return { status: 'idle' };
if (result?.src !== src) return { status: 'loading' };
return 'colors' in result ? { status: 'done', colors: result.colors } : { status: 'error', error: result.error };
}
結果は、どの src に対する結果かと一緒に state に入れます。いまの src の結果がまだなければ「読み込み中」とみなすので、src が変わったときに state を空に戻す処理は要りません。
ignore は、公式ドキュメントにある、Effect でデータを取得するときの書き方と同じ考え方です。画像の応答を0.8秒遅らせ、結果が届く前に別の画像を選ぶ操作で確かめました。ignore があるときは、あとで選んだ画像の結果が表示されたままでした。ignore の確認を外した版では、あとで選んだ画像の結果がいったん表示されたあと、先に選んだ画像の結果が遅れて届いて state を上書きしました。その結果は src が違うので表示には使われず、画面は「色を調べています…」のまま止まりました。
代表色をカードの背景にして、文字の色を選ぶ
フックを使うコンポーネントでは、代表色をカードの背景にし、その上の文字を黒か白のうち読みやすいほうにします。どちらが読みやすいかは、WCAG の相対輝度からコントラスト比を計算して比べ、比の大きいほうを選びます。
export const toHex = ({ r, g, b }: RGB) =>
'#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
// WCAG の相対輝度とコントラスト比で、背景の上で読みやすい文字の色(黒か白)を選ぶ
function luminance({ r, g, b }: RGB) {
const lin = (v: number) => {
const c = v / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
export function readableTextColor(bg: RGB) {
const l = luminance(bg);
const withBlack = (l + 0.05) / 0.05;
const withWhite = 1.05 / (l + 0.05);
return withBlack >= withWhite ? '#000000' : '#ffffff';
}
検証環境で計算したところ、ピンクの #d85878 は黒とのコントラスト比が 5.59、白とは 3.76 で、黒が選ばれました。濃い紫の #6c2f70 は黒と 2.28、白と 9.22 で、白が選ばれました。
コンポーネントでは、サンプル画像のボタンのほかに、手元の画像ファイルも選べるようにしています。ファイルは URL.createObjectURL() で blob URL にしてフックに渡し、別の画像に替わったら URL.revokeObjectURL() で解放します。
import { useEffect, useState } from 'react';
import { readableTextColor, toHex } from './imageColors';
import { useImageColors } from './useImageColors';
export function ColorCard({ samples }: { samples: { label: string; src: string }[] }) {
const [src, setSrc] = useState<string | null>(samples[0]?.src ?? null);
const [fileUrl, setFileUrl] = useState<string | null>(null);
const state = useImageColors(src);
// 選んだファイルの blob URL は、別の画像に替わったら解放する
useEffect(() => {
if (!fileUrl) return;
return () => URL.revokeObjectURL(fileUrl);
}, [fileUrl]);
const choose = (next: string, isFile = false) => {
setFileUrl(isFile ? next : null);
setSrc(next);
};
const bg = state.status === 'done' ? state.colors.dominant : null;
return (
<div className="color-card">
<div className="pickers">
{samples.map((s) => (
<button key={s.src} type="button" aria-pressed={src === s.src} onClick={() => choose(s.src)}>
{s.label}
</button>
))}
<input
type="file"
accept="image/*"
aria-label="画像ファイルを選ぶ"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) choose(URL.createObjectURL(file), true);
}}
/>
</div>
<figure
className="card"
style={bg ? { background: toHex(bg), color: readableTextColor(bg) } : undefined}
>
{src && <img src={src} alt="選んだ画像" crossOrigin="anonymous" />}
<figcaption role="status">
{state.status === 'loading' && '色を調べています…'}
{state.status === 'error' && state.error}
{state.status === 'done' && `代表色 ${toHex(state.colors.dominant)}(平均色 ${toHex(state.colors.average)})`}
</figcaption>
</figure>
{state.status === 'done' && (
<ul className="palette" aria-label="多い順の色">
{state.colors.palette.map(({ color, ratio }) => (
<li key={toHex(color)}>
<span className="chip" style={{ background: toHex(color) }} />
{toHex(color)} {Math.round(ratio * 100)}%
</li>
))}
</ul>
)}
</div>
);
}
ファイルを選んでから別のサンプルに戻す操作で、選んだファイルの blob URL が1回解放されたことを確かめました。StrictMode を付けた開発用ビルドでもサンプルを順に選び、React の警告やエラーが出ないことを確かめています。
いちばん多い色が、いつも背景に向いているとは限りません。白い背景の中央に水色の四角を置いた画像では、代表色は白(#ffffff、75%)で、水色の #04c9e8 は23%で2番目でした。ピンクの円だけの PNG では、背景も円と同じピンクになり、円が見えなくなりました(上の GIF の2回目の操作)。写っている物の色を使いたいときは、白に近い色を飛ばして palette の次の色を使う、背景の色を少し明るくする、といった調整が考えられます(この記事では試していません)。
別のオリジンの画像は、CORS で許可されていないと読めない
getImageData() は、別のオリジンから CORS の許可なしに読み込んだ画像を描いた canvas では SecurityError になります。MDN の別のオリジンの画像を canvas で使う方法の解説では、このような canvas を「tainted(汚染された)」と呼んでいます。
ページとは別のポートで画像を配信するサーバーを立て、Access-Control-Allow-Origin を付けるかどうかと、crossOrigin を付けるかどうかを変えて試した結果が次の表です。
| サーバーの応答 | crossOrigin |
結果 |
|---|---|---|
Access-Control-Allow-Origin: * あり |
'anonymous' |
代表色 #00c8e8 が取れた |
| なし | 'anonymous' |
decode() が EncodingError で失敗(画像そのものを読み込めない) |
| なし | 付けない | 画像は読み込めたが、getImageData() で SecurityError |
crossOrigin を付けないと、画像は表示できても色は読めません。付けると CORS の要求になるので、サーバーが許可していなければ読み込みの段階で失敗します。useImageColors() で crossOrigin を付けているのは、許可しているサーバーの画像を読めるようにするためです。ユーザーが選んだファイルを blob URL にして渡した場合は、検証では、この制限を受けずに色を取り出せました。
別のサーバーの画像の色を取りたいときは、そのサーバーが Access-Control-Allow-Origin を返すように設定するか、画像を自分のサイトと同じオリジンから配信します。





