Reactで、色を選ぶ入力欄を作る方法
色を選ぶ入力欄は、<input type="color"> を state で制御すれば、ライブラリを使わずに作れます。気を付けるのは値の形です。検証した Chromium では、色の input の値は小文字の #rrggbb で読み出されます。state もその形で持つと、ほかの値と比べたときに食い違いが起きません。
ここでは、色の input に、カラーコードを直接打てる欄と、よく使う色のボタンを並べた ColorField を作りました。値の形、onChange が呼ばれる回数、打ちかけのカラーコードの扱いを、検証環境で確かめています。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して、input の値や画面の表示を調べました。ブラウザが開く色の選択画面は、ヘッドレスの Chromium では操作できないため試していません。色の input の値は、Playwright の fill か、スクリプトからイベントを送る方法で変えています。Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げも試していません。

色の input、カラーコード欄、色のボタンを1つにまとめる
コンポーネントの全体は次のとおりです。色は小文字の #rrggbb の文字列として親から受け取り、変えるときは onChange で親に渡します。
import { useId, useState } from 'react';
export type Preset = { name: string; hex: string };
// "#FF8800" や "ff8800" を "#ff8800" にそろえる。6桁のカラーコードでなければ null
export function normalizeHex(text: string): string | null {
const m = /^#?([0-9a-f]{6})$/i.exec(text.trim());
return m ? `#${m[1].toLowerCase()}` : null;
}
type Props = {
label: string;
value: string; // 小文字の #rrggbb
onChange: (hex: string) => void;
presets?: Preset[];
name?: string;
};
export function ColorField({ label, value, onChange, presets = [], name }: Props) {
const id = useId();
// 入力欄にフォーカスがある間だけ、打ちかけの文字を持つ
const [draft, setDraft] = useState<string | null>(null);
const invalid = draft !== null && normalizeHex(draft) === null;
function handleTextChange(text: string) {
setDraft(text);
const hex = normalizeHex(text);
if (hex) onChange(hex);
}
return (
<fieldset className="color-field">
<legend>{label}</legend>
<div className="color-row">
<input
id={id}
type="color"
name={name}
value={value}
aria-label={`${label}を選ぶ`}
onChange={(e) => onChange(e.target.value)}
/>
<input
type="text"
className="color-code"
value={draft ?? value}
aria-label={`${label}のカラーコード`}
aria-invalid={invalid}
aria-describedby={`${id}-hint`}
spellCheck={false}
onFocus={() => setDraft(value)}
onChange={(e) => handleTextChange(e.target.value)}
onBlur={() => setDraft(null)}
/>
</div>
<p id={`${id}-hint`} className={invalid ? 'hint hint-invalid' : 'hint'}>
{invalid ? '#と6桁の16進数で入力してください(例:#00c8e8)' : '#と6桁の16進数で入力できます'}
</p>
{presets.length > 0 && (
<div className="presets">
{presets.map((p) => {
const hex = normalizeHex(p.hex) ?? p.hex;
return (
<button
key={hex}
type="button"
className="preset"
style={{ background: hex }}
aria-label={p.name}
aria-pressed={value === hex}
title={`${p.name}(${hex})`}
onClick={() => onChange(hex)}
/>
);
})}
</div>
)}
</fieldset>
);
}
色の input には value と onChange を渡しているので、controlled な input です。React の公式リファレンスにある、state で input を制御する説明のとおり、React は input の値を、渡した value に合わせ続けます。色の input、カラーコード欄、色のボタンのどれで色を変えても、同じ onChange を通ります。カラーコード欄で打ちかけている間を除けば、3つの表示は同じ色を指します。
3つの部品は <fieldset> でまとめ、<legend> の「テーマの色」を、まとまり全体の名前にしました。MDN の fieldset の説明にあるとおり、<legend> は <fieldset> の見出しになります。Playwright でアクセシビリティツリーを書き出すと、まとまりは「テーマの色」という名前の group になっていました。色の input とカラーコード欄には、aria-label で「テーマの色を選ぶ」「テーマの色のカラーコード」という別々の名前を付けました。
使う側では、色を state で持ち、いまの色を枠の色と文字で表示しました。よく使う色は、わざと大文字のカラーコードで書いています。
import { useState } from 'react';
import { ColorField, type Preset } from './ColorField';
const PRESETS: Preset[] = [
{ name: '水色', hex: '#00C8E8' },
{ name: 'さくら', hex: '#D85878' },
{ name: 'わかば', hex: '#2F9E44' },
{ name: 'すみ', hex: '#1F2A30' },
];
export function App() {
const [color, setColor] = useState('#00c8e8');
const [sent, setSent] = useState('');
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
setSent(JSON.stringify(Object.fromEntries(data)));
}
return (
<form onSubmit={handleSubmit}>
<ColorField label="テーマの色" value={color} onChange={setColor} presets={PRESETS} name="theme_color" />
<div className="preview" style={{ borderColor: color }}>
<span className="preview-badge" style={{ background: color }} />
<span className="preview-text">いまの色:<b className="current">{color}</b></span>
</div>
<button type="submit">保存</button>
<output className="sent">{sent}</output>
</form>
);
}
値は小文字の「#rrggbb」で読み出される
MDN の input type="color" の説明によると、今の仕様では、値に CSS の色の書き方をどれでも使えます。値がないときや、色として読めないときは #000000(黒)になります。
検証環境の Chromium で、React を使わずに色の input の value へいろいろな文字列を入れ、読み出した値を調べました。
何も入れないとき: #000000
value に "#FF8800" を入れる → #ff8800
value に "#f80" を入れる → #ff8800
value に "#00c8e" を入れる → #000000
value に "red" を入れる → #ff0000
value に "rgb(0 128 0)" を入れる → #008000
value に "#ff000080" を入れる → #ff0000
value に "oklch(70% 0.15 200)" を入れる → #00b9c3
value に "nonsense" を入れる → #000000
value に "" を入れる → #000000
'alpha' in input: false、'colorSpace' in input: false
alpha 属性を付けて "#ff000080" を入れる → #ff0000
色として読める書き方なら、読み出した値は小文字の #rrggbb でした。red や oklch() も、その色のカラーコードになります。5桁しかない #00c8e、色として読めない nonsense、空の文字列は、どれも #000000 でした。
MDN では、透明度を選べるようにする alpha 属性と、色空間を指定する colorspace 属性も、試験的な機能として紹介されています。ただ、検証に使った Chromium 153 では、input の要素に alpha や colorSpace のプロパティがありませんでした。alpha 属性を付けても、#ff000080 の透明度は捨てられて #ff0000 になりました。透明度まで選ばせたいときは、検証した Chromium では色の input だけでは足りず、別の入力欄が要ります。
React で困るのは、state に大文字のカラーコードを入れたときです。useState('#D85878') の値を色の input に渡して、state と input の値を比べました。
state: #D85878 / input の value 属性: #D85878 / input.value: #d85878
state は大文字のまま、input の値だけが小文字になっています。このまま、色の input の e.target.value(小文字)と、大文字で書いたよく使う色を === で比べると、同じ色なのに一致しません。そこで ColorField では、normalizeHex で値を小文字の #rrggbb にそろえてから比べたり、親に渡したりしています。
よく使う色を、押した状態が分かるボタンで並べる
よく使う色は、<button type="button"> で並べました。MDN の button の説明にあるとおり、フォームの中の button は、type を書かないと送信のボタンになります。検証環境でも、type="button" の色のボタンを押したあと、送信内容は空のままでした。いま選ばれている色のボタンには aria-pressed={true} を付けます。MDN の aria-pressed の説明にあるとおり、aria-pressed を付けたボタンは、押した状態と押していない状態を持つ切り替えボタンになります。ボタンには色しか描かれていないので、aria-label で「さくら」などの名前を付けました。見た目では、押されているボタンに濃い色の輪を付けています。
ボタンを押したときと、色の input の値が変わったときの表示を調べました。色の input の値は、Playwright の fill で変えています。
最初: 色の input #00c8e8 / カラーコード欄「#00c8e8」 / aria-invalid false / 押された色 水色 / いまの色 #00c8e8
「さくら」のボタンを押す: 色の input #d85878 / カラーコード欄「#d85878」 / aria-invalid false / 押された色 さくら / いまの色 #d85878
色の input に fill("#2F9E44"): 色の input #2f9e44 / カラーコード欄「#2f9e44」 / aria-invalid false / 押された色 わかば / いまの色 #2f9e44
色の input に fill("#123456"): 色の input #123456 / カラーコード欄「#123456」 / aria-invalid false / 押された色 なし / いまの色 #123456
「さくら」のボタンを押すと、App で大文字の #D85878 と書いた色が、小文字の #d85878 になって state に入りました。色の input に #2F9E44 を入れると、「わかば」のボタンが押された状態になります。
カラーコードを直接打てる欄を並べる
MDN の説明によると、色の選択画面の見た目や作りは、ブラウザやプラットフォームによって大きく違います。決まったカラーコードをどのブラウザでも同じように入れられるように、カラーコードを打てる <input type="text"> を隣に置きました。
この欄は、打ちかけの文字を、親の色とは別に持つ必要があります。たとえば #00c8e まで打った時点では、まだ色として読めません。この文字を親の色に入れてしまうと、前の節で見たとおり、色の input の値は #000000(黒)になります。
そこで ColorField では、欄にフォーカスがある間だけ、打った文字を draft という state に持たせました。onFocus でいまの色を draft に写し、文字が変わるたびに normalizeHex で読めるかどうかを調べます。# と6桁の16進数として読めたときだけ、親の onChange を呼びます。onBlur で draft を null に戻すと、欄の表示は親の色に戻ります。読めない文字のままフォーカスが外れても、最後に読めた色が残ります。
MDN の aria-invalid の説明にあるとおり、aria-invalid は、入力された値が期待される形に合っていないことを伝えます。読めない間はこれを true にして、欄の下の説明の文を、入力の例を添えた文に変えました。説明の文は aria-describedby で欄と結び付けています。
カラーコード欄で文字を打ったときの表示は次のとおりです。
カラーコード欄に「#00c8e」まで打つ: 色の input #123456 / カラーコード欄「#00c8e」 / aria-invalid true / 押された色 なし / いまの色 #123456
説明の文: #と6桁の16進数で入力してください(例:#00c8e8)
続けて「8」を打つ: 色の input #00c8e8 / カラーコード欄「#00c8e8」 / aria-invalid false / 押された色 水色 / いまの色 #00c8e8
「D85878」(# なし、大文字)と打ち直す: 色の input #d85878 / カラーコード欄「D85878」 / aria-invalid false / 押された色 さくら / いまの色 #d85878
「#12」と打ちかけて: 色の input #d85878 / カラーコード欄「#12」 / aria-invalid true / 押された色 さくら / いまの色 #d85878
フォーカスを外す: 色の input #d85878 / カラーコード欄「#d85878」 / aria-invalid false / 押された色 さくら / いまの色 #d85878
#00c8e までは色が変わらず、8 を打った時点で #00c8e8 になりました。# なしの大文字で D85878 と打っても、色は #d85878 です。#12 と打ちかけてフォーカスを外すと、欄は #d85878 に戻りました。
normalizeHex が受け付けるのは、# があってもなくてもよい6桁の16進数だけです。#f80 のような3桁の書き方や、red のような色の名前は、色の input の value に入れれば変換されますが、このカラーコード欄では読めない文字として扱います。
onChange は、色を選んでいる途中にも呼ばれる
MDN の input type="color" の説明によると、ブラウザは、色の選択画面で色が変わるたびに input イベントを送り、選択画面を閉じたときに change イベントを送ります。一方、React のリファレンスにある、onChange の説明では、onChange はブラウザの input イベントと同じように動くと書かれています。ドキュメントのとおりなら、名前は change でも、React の onChange は選んでいる途中の色の変化ごとに呼ばれることになります。
検証環境では色の選択画面を操作できないため、スクリプトから、選択画面の操作中と同じ順番でイベントを送って数えました。値を変えて input イベントを送ることを5回くり返し、最後に change イベントを1回送っています。React の onChange の回数と、addEventListener で付けたブラウザの change イベントの回数を数えました。
最初: value=#000000 onChange=0 change=0
input イベント5回と change イベント1回のあと: value=#550000 onChange=5 change=1
Playwright の fill("#660000") のあと: value=#660000 onChange=6 change=2
React の onChange は input イベントの5回すべてで呼ばれ、ブラウザの change イベントは1回だけでした。Playwright の fill では、onChange と change イベントが1回ずつ呼ばれました。
色をプレビューに映すだけなら、途中で何回呼ばれても困りません。選び終えた色だけをサーバーに保存したいときは、ref で input を受け取り、ブラウザの change イベントを addEventListener で付けます。検証に使ったのは次のコードです。
// 検証用:React の onChange と、ブラウザの change イベントが呼ばれる回数を数える
import { useEffect, useRef, useState } from 'react';
export function Events() {
const [color, setColor] = useState('#000000');
const [reactCount, setReactCount] = useState(0);
const [nativeCount, setNativeCount] = useState(0);
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
const el = ref.current!;
const onNativeChange = () => setNativeCount((n) => n + 1);
el.addEventListener('change', onNativeChange);
return () => el.removeEventListener('change', onNativeChange);
}, []);
return (
<div>
<input
ref={ref}
type="color"
aria-label="色"
value={color}
onChange={(e) => {
setColor(e.target.value);
setReactCount((n) => n + 1);
}}
/>
<p className="log">value={color} onChange={reactCount} change={nativeCount}</p>
</div>
);
}
フォームで送るときは name を付ける
色の input に name を付けておけば、ふつうの input と同じように、フォームの送信内容に入ります。App では name="theme_color" を渡し、送信時に FormData の中身を表示しました。
送信した内容: {"theme_color":"#d85878"}
送られたのは色の input の値で、小文字の #rrggbb でした。カラーコード欄には name を付けていないので、送信内容に入りません。





