Reactで、サイトの色をユーザーが選べるようにする方法
サイトのメインの色を利用者が選べるようにするには、色を CSS 変数1つ(ここでは --accent)にまとめ、選ばれた値を <html> の CSS 変数に入れます。React のコンポーネントが扱うのは「いま選ばれている色」だけで、ボタンや見出しの色は CSS が変数から作ります。選んだ色は localStorage に保存し、次に開いたときは、React より先に動く小さなスクリプトで戻します。
この記事のコードは、本番ビルドを Chromium で開き、ラジオボタンのクリックとキーボード操作、再読み込み、別のタブとのやりとりを確かめたものです。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で操作しました。StrictMode を付けた開発ビルドでも同じページを開いて色を選び、React の警告が出ないことを確かめています。Firefox と Safari、スマートフォンのブラウザ、Next.js などのフレームワークの上での動きは試していません。

色は CSS 変数1つにまとめ、ほかの色は color-mix() で作る
CSS では、選べる色を --accent という変数1つにしました。背景の上に載せる文字の色は --on-accent です。CSS 変数の使い方は、MDN のCSS カスタムプロパティ(変数)の使い方にまとまっています。
:root { --accent: #00c8e8; --on-accent: #1f2a30; }
body { margin: 0; font: 15px/1.6 'Noto Sans CJK JP', sans-serif; color: #1f2a30;
background: color-mix(in srgb, var(--accent) 12%, white); }
.site-header { background: var(--accent); color: var(--on-accent); padding: 12px 16px; font-weight: bold; font-size: 17px; }
.card h2 { color: color-mix(in srgb, var(--accent) 70%, black); font-size: 17px; margin: 8px 0 4px; }
.primary { background: var(--accent); color: var(--on-accent); border: 0; border-radius: 6px; padding: 6px 18px; font: inherit; }
ページの背景の薄い色と、見出しの濃い色は、color-mix() で --accent に白や黒を混ぜて作っています。こうしておくと、利用者が選ぶ色が1つでも、薄い色と濃い色が一緒に変わります。選べる色ごとに、薄い色と濃い色を用意しておく必要はありません。
検証環境で「ピンク」(#ea7896)を選んだところ、上の帯の背景は rgb(234, 120, 150) になり、ページの背景と見出しも、ピンクに白や黒を混ぜた色に変わりました。color-mix() で作った色は、getComputedStyle で読むと rgb() ではなく color(srgb 0.990118 0.936471 0.950588) のような形で返りました。テストで色を比べるときは、この形の違いに気をつけます。
選んだ色は、React の外の小さなストアに持つ
選んだ色は、localStorage と <html> の CSS 変数という、React の外にあるものと結びついています。別のタブで色が変わったときにも追いかけたいので、色はコンポーネントの state ではなく、モジュールの中の変数に持たせました。React からは useSyncExternalStore で読みます。
まず、保存した色を読む関数と、CSS 変数に入れる関数です。
import { textColorOn } from './contrast';
export const COLORS = [
{ label: '水色', value: '#00c8e8' },
{ label: 'ピンク', value: '#ea7896' },
{ label: '緑', value: '#2b7a3b' },
{ label: '紫', value: '#6c4bc4' },
] as const;
export const DEFAULT_COLOR = '#00c8e8';
const STORAGE_KEY = 'theme-color';
const HEX = /^#[0-9a-f]{6}$/;
// localStorage の値は書き換えられることがあるので、#rrggbb の形のときだけ使う
export function readStoredColor(): string {
try {
const v = localStorage.getItem(STORAGE_KEY)?.toLowerCase();
if (v && HEX.test(v)) return v;
} catch {
// localStorage が使えない設定のブラウザでは、既定の色にする
}
return DEFAULT_COLOR;
}
// <html> に CSS 変数を設定する。色を使う側は、var(--accent) などで読む
export function applyThemeColor(color: string) {
const root = document.documentElement.style;
root.setProperty('--accent', color);
root.setProperty('--on-accent', textColorOn(color));
}
CSS 変数は、style.setProperty() で <html> の style 属性に入れます。MDN の詳細度の説明にあるとおり、style 属性の値は、CSS ファイルに書いた通常の指定より優先されます。そのため、:root に書いた既定の値を上書きできます。textColorOn は、背景の色に合わせて文字の色を選ぶ関数で、後の節で説明します。
次に、色を持つストアとフックです。
import { useSyncExternalStore } from 'react';
import { applyThemeColor, DEFAULT_COLOR, readStoredColor } from './theme';
let current = readStoredColor();
const listeners = new Set<() => void>();
function emit() {
for (const l of listeners) l();
}
function subscribe(listener: () => void) {
listeners.add(listener);
// 別のタブで色が変わったら、このタブにも反映する
const onStorage = (e: StorageEvent) => {
if (e.key !== 'theme-color' && e.key !== null) return;
current = readStoredColor();
applyThemeColor(current);
emit();
};
window.addEventListener('storage', onStorage);
return () => {
listeners.delete(listener);
window.removeEventListener('storage', onStorage);
};
}
export function setThemeColor(color: string) {
current = color;
try {
localStorage.setItem('theme-color', color);
} catch {
// 保存できなくても、開いているあいだは選んだ色を使う
}
applyThemeColor(color);
emit();
}
export function useThemeColor() {
return useSyncExternalStore(subscribe, () => current, () => DEFAULT_COLOR);
}
setThemeColor は、色を変える処理を1か所にまとめた関数です。変数を書き換え、localStorage に保存し、CSS 変数に入れ、フックを使っているコンポーネントに知らせます。色を変える処理の中で CSS 変数も入れるので、色は押した直後に変わります。
storage イベントは、同じオリジンのほかのタブが localStorage を書き換えたときに届きます。書き換えたタブ自身には届きません。そのため、自分のタブの変更は emit() で知らせ、ほかのタブの変更は storage イベントで受け取ります。
検証環境で同じブラウザに2つのタブを開き、1つ目のタブで「緑」を選んだところ、2つ目のタブも緑になり、ラジオボタンも「緑」に移りました。1つ目のタブで localStorage.clear() を呼ぶと、2つ目のタブに届いた storage イベントの e.key は null で、色は既定の水色に戻りました。e.key !== null の条件は、この場合のためです。
色を選ぶ部品は、ラジオボタンで作る
いくつかの色から1つを選ぶので、部品は同じ name を付けたラジオボタンにしました。MDN の <input type="radio"> のページにあるとおり、同じ name のラジオボタンは1つのグループになり、選べるのは1つだけです。色の丸は飾りなので、aria-hidden を付けて読み上げの対象から外し、色の名前を文字で並べています。
import { COLORS } from './theme';
import { setThemeColor, useThemeColor } from './useThemeColor';
export function ThemeColorPicker() {
const color = useThemeColor();
const isPreset = COLORS.some((c) => c.value === color);
return (
<fieldset className="picker">
<legend>サイトの色</legend>
{COLORS.map((c) => (
<label key={c.value} className="swatch">
<input
type="radio"
name="theme-color"
value={c.value}
checked={color === c.value}
onChange={() => setThemeColor(c.value)}
/>
<span className="dot" style={{ background: c.value }} aria-hidden="true" />
{c.label}
</label>
))}
<label className="swatch">
<input type="color" value={color} onChange={(e) => setThemeColor(e.target.value)} />
{isPreset ? '自分で選ぶ' : `自分で選んだ色(${color})`}
</label>
</fieldset>
);
}
ラジオボタンにしておくと、キーボードでも選べます。検証環境で「ピンク」にフォーカスを置き、→ キーを1回押すと「緑」に、もう1回押すと「紫」に移り、そのたびに --accent も変わりました。キーで移ったときにも onChange が呼ばれるので、クリックと同じ処理で済みます。
最後の <input type="color"> は、決まった色以外を選びたい人のための欄です。色の選択ダイアログは Playwright から操作できないため、検証では input に #1d3a8a を直接入れて試しました。すると --accent は #1d3a8a になり、どのラジオボタンも選ばれていない状態になりました。
背景の色に合わせて、文字を白か濃い色にする
利用者が暗い色を選ぶと、上の帯やボタンの濃い文字が読みにくくなります。そこで、背景の色に対して、白と濃い色(#1f2a30)のどちらの文字が読みやすいかを計算し、--on-accent に入れました。計算には、WCAG の相対輝度とコントラスト比の式を使います。
// WCAG の相対輝度とコントラスト比で、背景の色の上に載せる文字の色を選ぶ
function channel(v: number): number {
const c = v / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
export function luminance(hex: string): number {
const n = parseInt(hex.slice(1), 16);
return 0.2126 * channel((n >> 16) & 255) + 0.7152 * channel((n >> 8) & 255) + 0.0722 * channel(n & 255);
}
export function contrast(a: string, b: string): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
// 白と濃い色のうち、背景とのコントラスト比が高いほうを返す
export function textColorOn(bg: string): string {
return contrast(bg, '#ffffff') >= contrast(bg, '#1f2a30') ? '#ffffff' : '#1f2a30';
}
選べる4色に、色の input で選ぶ例として黄色と紺の2色、比べるために明るさが中くらいのピンクと緑の2色を加えて計算した結果です。
水色 #00c8e8: 白との比 2.01 / #1f2a30 との比 7.28 → 文字の色 #1f2a30(4.5 以上)
ピンク #ea7896: 白との比 2.75 / #1f2a30 との比 5.32 → 文字の色 #1f2a30(4.5 以上)
緑 #2b7a3b: 白との比 5.32 / #1f2a30 との比 2.76 → 文字の色 #ffffff(4.5 以上)
紫 #6c4bc4: 白との比 6.10 / #1f2a30 との比 2.40 → 文字の色 #ffffff(4.5 以上)
黄色(自分で選んだ例) #f5d000: 白との比 1.51 / #1f2a30 との比 9.71 → 文字の色 #1f2a30(4.5 以上)
紺(自分で選んだ例) #1d3a8a: 白との比 10.37 / #1f2a30 との比 1.41 → 文字の色 #ffffff(4.5 以上)
明るさが中くらいのピンク(届かない例) #d85878: 白との比 3.76 / #1f2a30 との比 3.90 → 文字の色 #1f2a30(4.5 未満)
明るさが中くらいの緑(届かない例) #2f9e44: 白との比 3.45 / #1f2a30 との比 4.26 → 文字の色 #1f2a30(4.5 未満)
冒頭の GIF でも、ピンクを選んだときは上の帯とボタンの文字が濃い色に、紫と緑を選んだときは白になっています。
WCAG のレベル AA の達成基準 1.4.3 では、文字と背景のコントラスト比を 4.5:1 以上(大きな文字は 3:1 以上)にすることを求めています。見本の4色は、どれも白か濃い色のどちらかの文字と 4.5 以上の比が取れます。
ただし、明るさが中くらいの色には、文字の色を切り替えるだけでは足りないものがあります。上の結果のピンク(#d85878)と緑(#2f9e44)は、白と濃い色のどちらを選んでも、比が 4.5 に届きませんでした。見本のピンク(#ea7896)は、このピンクを明るくして、濃い色の文字と比が 4.5 以上になるようにした色です。見本の緑(#2b7a3b)は、この緑を暗くして、白い文字と比が 4.5 以上になるようにした色です。選べる色を決めるときはこの計算で確かめ、足りない色は、少し暗くするか明るくするかして、比が届く色にします。
明るさが中くらいの色には、文字の色を切り替えても 4.5:1 に届かないものがあります。選べる色そのものを、白か濃い色の文字と 4.5:1 以上の比が取れる色にしておきます。
再読み込みしたときに、前の色が一瞬出ないようにする
保存した色を戻す処理を React のコードの中だけに書くと、JS が読み込まれて動くまでのあいだ、CSS に書いた既定の水色で表示されます。サーバーで描いた HTML を使うときは、その HTML がまず水色で表示され、JS が動いたところでピンクに変わります。
そこで、保存した色を CSS 変数に入れるだけの小さなスクリプトを作り、<head> で読み込みました。MDN の <script> のページによると、async・defer・type="module" のどれも付けない <script> は、HTML の解析を止めて実行されます。<head> に置けば、<body> の中身が読まれる前に色が入ります。
// <head> で、描画より前に読み込む小さなスクリプト。保存された色を CSS 変数に入れる
import { applyThemeColor, readStoredColor } from './theme';
applyThemeColor(readStoredColor());
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>サイトの色</title>
<link rel="stylesheet" href="/style.css">
<script src="/theme-init.js"></script>
</head>
このファイルは、React のコードとは別に、esbuild で即時実行の形(--format=iife)にまとめました。readStoredColor と applyThemeColor を React のコードと共有しているので、色を読む処理を2か所に書かずに済みます。まとめたあとの大きさは 623 バイトでした。
効果を確かめるため、「ピンク」を保存した状態で、React のコードの読み込みだけを1.5秒遅らせ、ページを開いてから0.7秒後の画面を比べました。

React だけで設定(サーバーで描いた HTML)
0.7秒後: --accent=#00c8e8 / header=rgb(0, 200, 232) / 選ばれているラジオボタン=水色
JS が届いたあと: --accent=#ea7896 / header=rgb(234, 120, 150) / 選ばれているラジオボタン=ピンク(開いてから 1.9 秒)
head で theme-init.js(サーバーで描いた HTML)
0.7秒後: --accent=#ea7896 / header=rgb(234, 120, 150) / 選ばれているラジオボタン=水色
JS が届いたあと: --accent=#ea7896 / header=rgb(234, 120, 150) / 選ばれているラジオボタン=ピンク(開いてから 1.8 秒)
(ログの行は一部を省いています)
theme-init.js を読み込まないページでは、JS が届くまで水色で表示され、届いたところでピンクに変わりました。読み込んだページでは、最初からピンクでした。サーバーで描かず、空の <div id="root"> に React が描くページでも、JS が届く前の --accent は、読み込まないページで水色、読み込んだページでピンクと、同じように分かれました。
ただし、theme-init.js を読み込んだページでも、JS が届くまで、ラジオボタンは「水色」が選ばれた表示のままでした。サーバーで描くときは、useSyncExternalStore の3つ目の引数 getServerSnapshot の値(既定の水色)で HTML を作るからです。React の公式リファレンスにあるとおり、ハイドレーションのときもこの値が使われます。検証環境では、ハイドレーションのあとに getSnapshot の値(保存したピンク)で描き直され、ラジオボタンが「ピンク」に移りました。開発ビルドでも、React の警告は出ませんでした。色の表示は先に合わせ、選んでいる部品の表示は JS が届いてから合わせる、という分担になります。
localStorage の値は、そのまま使わない
localStorage の値は、利用者が開発者ツールで書き換えたり、古い版のコードが別の形で保存していたりすることがあります。readStoredColor では、#rrggbb の形のときだけ使い、それ以外は既定の色にしています。検証環境で、保存した値を書き換えて再読み込みした結果です。
"red" → --accent #00c8e8 / 選ばれているラジオボタン 水色
"#12" → --accent #00c8e8 / 選ばれているラジオボタン 水色
"url(https://example.com/x.png)" → --accent #00c8e8 / 選ばれているラジオボタン 水色
"#EA7896" → --accent #ea7896 / 選ばれているラジオボタン ピンク
大文字の #EA7896 は小文字にそろえてから比べるので、ラジオボタンの「ピンク」と一致しました。
また、MDN の localStorage のページによると、ブラウザの設定でページがデータを残すことを禁じているときなどは、localStorage を読むだけで SecurityError が投げられます。readStoredColor と setThemeColor を try で囲んでいるのはこのためです。検証環境で localStorage.setItem が必ず例外を投げるようにしたページでも、「紫」を押すと色は変わり、ページのエラーは出ませんでした。保存はできていないので、再読み込みすると既定の水色に戻りました。





