Reactで、画面の色をスポイトで取る方法
画面の色をスポイトで取るには、ブラウザの EyeDropper API を使います。ボタンのクリックで new EyeDropper().open() を呼ぶと、カーソルが拡大鏡に変わり、利用者がクリックした場所の色が、ふつうは { sRGBHex: "#00c8e8" } の形で返ってきます。React では、開いている間の状態と、途中でやめるための AbortController を、カスタムフックにまとめておくと扱いやすくなります。
検証環境は 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 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドしました。スポイトはヘッドレスのブラウザでは操作できないため、Xvfb の仮想の画面(1280×800、ウィンドウマネージャーなし)でウィンドウありの Chromium を開き、xdotool で X のマウスとキーを動かして色を取っています。Windows と macOS、Wayland の Linux、Google Chrome と Microsoft Edge の製品版、複数の画面や高解像度の画面、サーバーレンダリングは試していません。

EyeDropper の型を宣言する
検証に使った TypeScript 7.0.2 の DOM の型には、EyeDropper が入っていませんでした。宣言なしで new EyeDropper() と書くと、Cannot find name 'EyeDropper'. のエラーになります。そこで、使う分だけを自分で宣言します。
// TypeScript の DOM の型には EyeDropper がないので、自分で宣言する
interface ColorSelectionResult {
sRGBHex: string;
}
interface ColorSelectionOptions {
signal?: AbortSignal;
}
declare class EyeDropper {
open(options?: ColorSelectionOptions): Promise<ColorSelectionResult>;
}
import も export も書かない .d.ts ファイル(ここでは src/eyedropper.d.ts)に置くと、どのファイルからでも使える宣言になります。形は MDN の open() のページに合わせました。open() は signal を持つオブジェクトを受け取れて、選んだ色を sRGBHex に入れたオブジェクトで返します。
スポイトを開くフックを作る
import { useCallback, useEffect, useRef, useState } from 'react';
export const isEyeDropperSupported = typeof window !== 'undefined' && 'EyeDropper' in window;
// sRGBHex は "#rrggbb" のほか、"rgba(60, 110, 71, 0)" の形で返ることもある。
// どちらも "#rrggbb" にそろえる(透明度は捨てる)
export function toHex(value: string): string | null {
if (/^#[0-9a-f]{6}$/i.test(value)) return value.toLowerCase();
const m = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (!m) return null;
return '#' + m.slice(1, 4).map((n) => Number(n).toString(16).padStart(2, '0')).join('');
}
export function useEyeDropper() {
const [picking, setPicking] = useState(false);
const controllerRef = useRef<AbortController | null>(null);
// 色を選んでいる途中でコンポーネントが取り除かれたら、スポイトを閉じる
useEffect(() => () => controllerRef.current?.abort(), []);
// 取った色を "#rrggbb" で返す。Esc などで取り消されたときは null を返す
const pick = useCallback(async (): Promise<string | null> => {
if (!isEyeDropperSupported || controllerRef.current) return null;
const controller = new AbortController();
controllerRef.current = controller;
setPicking(true);
try {
const { sRGBHex } = await new EyeDropper().open({ signal: controller.signal });
const hex = toHex(sRGBHex);
if (!hex) throw new Error(`読めない色の形です: ${sRGBHex}`);
return hex;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return null;
throw e;
} finally {
controllerRef.current = null;
setPicking(false);
}
}, []);
return { supported: isEyeDropperSupported, picking, pick };
}
pick() は、色を取れたら "#rrggbb" を、利用者がやめたら null を返します。MDN の open() のページにあるとおり、Esc キーでやめたときも、AbortController で止めたときも、Promise は AbortError で失敗します。どちらも「色を選ばなかった」という意味なので、エラーとしては扱わず null にしています。それ以外のエラーは、呼び出した側に投げ直します。
AbortController は ref に持ちます。画面に出す値ではなく、止めるときに取り出せればよいからです。useEffect のクリーンアップで abort() を呼んでおくと、スポイトを開いたままコンポーネントが画面から取り除かれたときに、スポイトも閉じます。
取った色を表示するコンポーネント
import { useState } from 'react';
import { useEyeDropper } from './useEyeDropper';
export function ColorDropper() {
const { supported, picking, pick } = useEyeDropper();
const [color, setColor] = useState('#ffffff');
const [history, setHistory] = useState<string[]>([]);
const [message, setMessage] = useState('');
async function handleClick() {
try {
const hex = await pick();
if (hex === null) {
setMessage('色を取るのをやめました');
return;
}
setColor(hex);
setHistory((prev) => [hex, ...prev.filter((c) => c !== hex)].slice(0, 5));
setMessage(`${hex} を取りました`);
} catch (e) {
setMessage(e instanceof Error ? `色を取れませんでした(${e.message})` : '色を取れませんでした');
}
}
if (!supported) {
// スポイトが使えないブラウザでは、色の input で選んでもらう
return (
<label className="dropper">
色を選ぶ
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} />
</label>
);
}
return (
<div className="dropper">
<button type="button" onClick={handleClick} disabled={picking}>
{picking ? '色を選んでいます…(Escでやめる)' : 'スポイトで色を取る'}
</button>
<p className="current">
<span className="swatch" style={{ background: color }} aria-hidden="true" />
<output>{color}</output>
</p>
<p className="message" aria-live="polite">{message}</p>
{history.length > 0 && (
<ul className="history" aria-label="取った色">
{history.map((c) => (
<li key={c}>
<span className="swatch" style={{ background: c }} aria-hidden="true" />
{c}
</li>
))}
</ul>
)}
</div>
);
}
取った色は、新しいものを先頭にして5つまで残します。同じ色をもう一度取ったときは、前の位置から消して先頭に移します。検証環境で「空」「桃」「草」「半透明」の順に取ってから、もう一度「空」を取ると、履歴は #00c8e8、#ebabbb、#5fae5a、#d85878 の順になり、#00c8e8 が2つ並ぶことはありませんでした。
スポイトを開いている間、ボタンは disabled にして、文字を「色を選んでいます…(Escでやめる)」に変えます。MDN の EyeDropper API の説明にあるとおり、スポイトの最中は、画面のどこをクリックしても色を選ぶ操作になります。利用者がやめる方法は Esc キーだけなので、それをボタンの文字で伝えています。
スポイトを開けるのは、クリックの直後だけ
MDN の EyeDropper API の説明にあるとおり、open() はボタンのクリックのような利用者の操作に応じてしか呼べません。利用者が知らないうちに画面の色を読み取られないようにするためです。検証環境で、呼ぶタイミングを変えて試した結果は次のとおりです。
| 呼んだタイミング | 結果 |
|---|---|
| ページを開いた直後(操作なし) | NotAllowedError |
| クリックから 100ms 待った後 | スポイトが開いた |
| クリックから 6秒待った後 | NotAllowedError |
NotAllowedError のメッセージの終わりは、どちらも EyeDropper::open() requires user gesture. でした。クリックのあとしばらくは、操作の直後とみなされます(MDN の用語集では transient activation と呼んでいます)。この状態が続く時間は、HTML の仕様で「長くても数秒」とされています。クリックのハンドラーの中で、ほかの処理の終わりを await で待ってから open() を呼ぶと、待ち時間が長いときは上の表の 6秒の場合と同じく失敗します。open() は、ハンドラーの中で先に呼びます。
やめ方は Esc キーと AbortController の2つ
スポイトを開いたあとで Esc キーを押すと、open() は AbortError で失敗し、メッセージの終わりは The user canceled the selection. でした。フックはこれを null にするので、画面には「色を取るのをやめました」と出ます。
コードからやめるときは、open() に渡した signal の AbortController で abort() を呼びます。検証環境では、AbortError で失敗し、メッセージは signal is aborted without reason でした。フックのクリーンアップがこれに当たります。スポイトを開いたままコンポーネントを取り除くと abort() が呼ばれ、そのあと「桃」をクリックしても色は取られず、ページの普通のクリックとして扱われました。
開いている最中に、もう一度 open() を呼ぶと、InvalidStateError で失敗します。メッセージの終わりは EyeDropper is already open. でした。フックでは、controllerRef.current があるときは open() を呼ばずに null を返し、ボタンも disabled にして、2回目を呼ばないようにしています。
取れるのは画面に見えている色
スポイトが読むのは、要素の CSS の値ではなく、画面に表示されている点の色です。MDN の EyeDropper API の説明にも、ブラウザのウィンドウの外も含めて、画面の色を取れると書かれています。
検証環境では、白い背景の上に #d85878 を不透明度 50% で重ねた「半透明」の見本を取ると、#ebabbb になりました。#d85878 と白を半分ずつ混ぜた色です。CSS で指定した #d85878 ではなく、白と重なって見えている色が返ってきます。
ウィンドウの外も試しました。Xvfb の画面の背景を xsetroot で #3c6e47 にしておき、幅 640px のウィンドウの右にある背景(x=740、y=300)をクリックしたところ、#3c6e47 が取れました。
スポイトでクリックしたとき、その下にある要素のクリックイベントは起きませんでした。見本に onClick を付けて数えると、スポイトで4回クリックしたあとも 0回のままでした。
値が rgba() の形で返ることがある
MDN の open() のページでは、sRGBHex は #aabbcc の形とされています。ところが、検証環境でウィンドウの外の背景を取ったときは、sRGBHex の値がそのまま "rgba(60, 110, 71, 0)" でした。同じ手順でページの中の「空」を取ったときは "#00c8e8" でした。
Chromium のソースでは、取った色を SerializeAsCanvasColor() で文字列にしています。検証環境で canvas の fillStyle に色を入れて読み出すと、不透明な色は #3c6e47 になり、rgba(60, 110, 71, 0) を入れるとそのままの形で読み出され、スポイトが返した値と同じでした。ウィンドウの外の点は透明度 0 の色として返ってきたので、rgba() の形になっています。
フックの toHex() は、この形も受け取って #rrggbb にそろえます。透明度は捨てて、赤・緑・青の値だけを使います。検証環境で取れた rgba(60, 110, 71, 0) は #3c6e47 になり、画面の背景に塗った色と一致しました。どちらの形でもない値が来たときは、null を返し、フックはエラーとして投げ直します。
sRGBHex を <input type="color"> の value などに渡すなら、#rrggbb の形かどうかを確かめてから使います。
使えないブラウザでは、色の input を出す
MDN のブラウザの対応表のもとになっているデータによると、EyeDropper API は Chrome 95 から使え、デスクトップの Edge や Opera も同じです。Firefox と Safari、Android の Chrome は対応していません。Linux では X11 なら使えて、Wayland では使えないと書かれています。
そこで、'EyeDropper' in window で、使えるかどうかを先に調べます。EyeDropper API は、HTTPS のような安全なページ(secure context)でしか使えません(MDN の EyeDropper のページ)。検証環境では、http://localhost のページでは isSecureContext が true で EyeDropper がありました。Playwright の setContent() で開いた about:blank のページでは isSecureContext が false になり、EyeDropper はありませんでした。
使えないときは、ColorDropper がスポイトのボタンの代わりに <input type="color"> を出します。ヘッドレスの Chromium で、ページを開く前に window.EyeDropper を消してから表示すると、スポイトのボタンは出ず、「色を選ぶ」のラベルが付いた色の input が1つ出ました。値を #5fae5a にすると、input の値も #5fae5a になりました。色の input の扱い方は、以前の記事「Reactで、色を選ぶ入力欄を作る方法」で詳しく確かめています。





