Reactで、CSSのグラデーションを試せるツールを作る方法
CSS の linear-gradient() を手で書くと、角度や色の位置を少し変えるたびに、どう見えるかを確かめ直すことになります。React で、角度と色の位置をスライダーで、色を色選びの欄で変えるとプレビューがその場で変わり、できた CSS をコピーできるツールを作ります。
state に持つのは角度・色の混ぜ方・色の並びだけで、CSS の文字列は描画のたびにそこから作ります。色の位置は、CSS に書き出すときだけ小さい順に並べ直します。
検証環境は 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 で変換し、本番ビルドを Playwright で操作しました。プレビューの色は、スクリーンショットから読み取って確かめています。StrictMode を付けた開発ビルドでも出力は本番ビルドと同じで、どちらもコンソールにエラーや警告は出ませんでした。色選びの欄は、ブラウザが開く色選びの画面を使わず、Playwright で値を入れています。Firefox と Safari、スマートフォンのブラウザ、radial-gradient() などほかの種類のグラデーションは試していません。

state は角度・色の混ぜ方・色の並びだけにし、CSS の文字列はそこから作る
ツールが扱うのは、角度、色の混ぜ方、色の並び(色と位置の組)です。CSS の文字列は、これらからいつでも作れます。公式ドキュメントの、state の構造を決めるときの原則にあるとおり、ほかの state から計算できる値は state に入れないほうが、値どうしの食い違いが起きません。そこで、CSS の文字列は関数 toCss で作ることにしました。
export type Stop = { id: number; color: string; pos: number }; // pos は 0〜100(%)
export type Space = 'srgb' | 'oklch';
export type Gradient = { angle: number; space: Space; stops: Stop[] };
// 状態から CSS の値を作る。色の位置は小さい順に並べ直してから書き出す
export function toCss(g: Gradient): string {
const stops = [...g.stops].sort((a, b) => a.pos - b.pos).map((s) => `${s.color} ${s.pos}%`);
const head = g.space === 'srgb' ? `${g.angle}deg` : `${g.angle}deg in ${g.space}`;
return `linear-gradient(${head}, ${stops.join(', ')})`;
}
// 新しい色を足す位置:いちばん広く空いている2色の真ん中
export function nextPos(stops: Stop[]): number {
const p = [0, ...stops.map((s) => s.pos).sort((a, b) => a - b), 100];
let best = 0;
for (let i = 1; i < p.length; i++) if (p[i] - p[i - 1] > p[best + 1] - p[best]) best = i - 1;
return Math.round((p[best] + p[best + 1]) / 2);
}
toCss と nextPos に値を渡して、結果を出力しました。「並びが逆」の行は、位置が 80%、20% の順に並んだ色を渡したものです。
--- toCss ---
linear-gradient(90deg, #00c8e8 0%, #d85878 100%)
linear-gradient(45deg in oklch, #0000ff 0%, #ffff00 100%)
並びが逆: linear-gradient(90deg, #0000ff 20%, #ff0000 80%)
--- nextPos ---
0, 100 → 50
0, 50, 100 → 25
0, 20, 100 → 60
30, 100 → 65
角度は、MDN の linear-gradient() の説明にあるとおり、0deg が下から上へ向かう to top と同じで、値が大きくなると時計回りに回ります。90deg なら左から右です。
nextPos は、新しく足す色の位置を決める関数です。左端(0%)と右端(100%)も含めて、色と色の間がいちばん広く空いているところの真ん中を返します。
色の位置は、書き出すときだけ並べ直す
toCss で並べ直しているのは、CSS の色の位置は小さい順に書く前提になっているからです。MDN の linear-gradient() の説明にも、色の位置は小さい順に並べるよう書かれています。CSS Images Module Level 3 の、色の位置を直す手順によると、前の色より小さい位置を書いた色は、それより前にある位置のうち最も大きい値に置き換えられます。2つの色が同じ位置に並ぶので、そこはグラデーションにならず、その位置ではっきり切り替わります。
位置が小さい順でない値をそのまま要素に指定した場合と、並べ直した場合で、プレビューの色を横の位置ごとに読み取りました(左端を 0%、右端を 100% としています)。
linear-gradient(90deg, #ff0000 80%, #0000ff 20%)
色: 10%:rgb(255,0,0) 30%:rgb(255,0,0) 50%:rgb(255,0,0) 70%:rgb(255,0,0) 79%:rgb(255,0,0) 81%:rgb(0,0,255) 90%:rgb(0,0,255)
linear-gradient(90deg, #0000ff 20%, #ff0000 80%)
色: 10%:rgb(0,0,255) 30%:rgb(43,0,212) 50%:rgb(128,0,127) 70%:rgb(212,0,43) 79%:rgb(250,0,5) 81%:rgb(255,0,0) 90%:rgb(255,0,0)
そのまま書いた値は、80% まで赤のままで、80% を境に青へ切り替わりました。並べ直した値は、20% から 80% にかけて青から赤へ少しずつ変わっています。スライダーで位置を動かしていると、色の順番と位置の順番はすぐに入れ替わるので、書き出す側で並べ直しておきます。
一方、画面の行は足した順のままにしています。行まで位置の順に並べ替えると、スライダーを動かしている途中で、その行が別の場所へ移ってしまうからです。行の key には、配列の位置ではなく、色を足したときに振る番号(id)を使っています。公式ドキュメントの、リストを表示する方法の説明では、手元で作るデータの key の作り方として、増やしていくカウンターや crypto.randomUUID() が挙げられています。
操作はフックにまとめる
角度や色の変更、色の追加と削除は、useGradient フックにまとめました。色は2つ以上ないとグラデーションにならないので、2つのときは削除しません。
import { useState } from 'react';
import { type Gradient, type Space, type Stop, nextPos, toCss } from './gradient';
const initial: Gradient = {
angle: 90,
space: 'srgb',
stops: [
{ id: 1, color: '#00c8e8', pos: 0 },
{ id: 2, color: '#d85878', pos: 100 },
],
};
export function useGradient() {
const [g, setG] = useState<Gradient>(initial);
const [nextId, setNextId] = useState(3);
const setAngle = (angle: number) => setG({ ...g, angle });
const setSpace = (space: Space) => setG({ ...g, space });
const updateStop = (id: number, patch: Partial<Omit<Stop, 'id'>>) =>
setG({ ...g, stops: g.stops.map((s) => (s.id === id ? { ...s, ...patch } : s)) });
const addStop = () => {
setG({ ...g, stops: [...g.stops, { id: nextId, color: '#ffffff', pos: nextPos(g.stops) }] });
setNextId(nextId + 1);
};
const removeStop = (id: number) => {
if (g.stops.length <= 2) return; // グラデーションには2色以上が要る
setG({ ...g, stops: g.stops.filter((s) => s.id !== id) });
};
return { gradient: g, css: toCss(g), setAngle, setSpace, updateStop, addStop, removeStop };
}
画面にプレビューとコピーのボタンを置く
作った CSS の文字列は、そのままプレビューの style の backgroundImage に渡します。下には background-image: …; の形で表示し、ボタンでクリップボードにコピーできるようにしました。
import { useState } from 'react';
import type { Space } from './gradient';
import { useGradient } from './useGradient';
export function GradientTool() {
const { gradient, css, setAngle, setSpace, updateStop, addStop, removeStop } = useGradient();
const [copied, setCopied] = useState('');
const supported = CSS.supports('background-image', css);
const declaration = `background-image: ${css};`;
const copy = async () => {
try {
await navigator.clipboard.writeText(declaration);
setCopied('コピーしました');
} catch {
setCopied('コピーできませんでした');
}
};
return (
<div>
<div className="preview" style={{ backgroundImage: css }} />
{!supported && <p role="alert">このブラウザでは、この値を表示できません</p>}
<label>
角度 <input type="range" min={0} max={360} value={gradient.angle}
onChange={(e) => setAngle(e.target.valueAsNumber)} /> {gradient.angle}deg
</label>
<label>
色の混ぜ方{' '}
<select value={gradient.space} onChange={(e) => setSpace(e.target.value as Space)}>
<option value="srgb">sRGB(指定なし)</option>
<option value="oklch">OKLCH</option>
</select>
</label>
<button onClick={addStop}>色を足す</button>
<ul>
{gradient.stops.map((s, i) => (
<li key={s.id}>
<input type="color" aria-label={`${i + 1}つ目の色`} value={s.color}
onChange={(e) => updateStop(s.id, { color: e.target.value })} />
<input type="range" aria-label={`${i + 1}つ目の位置`} min={0} max={100} value={s.pos}
onChange={(e) => updateStop(s.id, { pos: e.target.valueAsNumber })} /> {s.pos}%
<button onClick={() => removeStop(s.id)} disabled={gradient.stops.length <= 2}>削除</button>
</li>
))}
</ul>
<pre><code>{declaration}</code></pre>
<button onClick={copy}>CSS をコピー</button> <span aria-live="polite">{copied}</span>
</div>
);
}
CSS.supports() は、MDN の CSS.supports() の説明にあるとおり、プロパティ名と値を渡すと、ブラウザがその組み合わせに対応しているかを true か false で返します。in oklch のような新しい書き方に対応していないブラウザで開いたときに、プレビューが表示されない理由を画面に出すために使っています。筆者の検証環境ではどの値も true になったので、この表示が出る場合は試していません。
「色を足す」のボタンは、色の行の下ではなく上に置きました。下に置くと、色を足すたびにボタンが1行分下へずれ、続けて押すときに押す場所が毎回変わります。
コピーには navigator.clipboard.writeText() を使っています。MDN の writeText() の説明にあるとおり、クリップボードへの書き込みは HTTPS などの安全なコンテキストでしかできず、使えないときは失敗します。そのときは「コピーできませんでした」と出すようにしましたが、失敗する場合は試していません。なお、MDN の安全なコンテキストの説明にあるとおり、127.0.0.1 や localhost のページは、HTTP でも安全なコンテキストとして扱われます。
プレビューの色を読み取って、作った CSS を確かめる
ツールが出した CSS がブラウザで意図どおりに描かれているかは、プレビューのスクリーンショットから色を読み取って確かめました。Playwright で撮った PNG をページの中で OffscreenCanvas に描き、横の決まった位置の色を読みます。
// 要素のスクリーンショットを撮り、横方向の決まった位置の色(縦の真ん中)を読む
export async function sample(page, locator, fractions) {
const png = (await locator.screenshot()).toString('base64');
return page.evaluate(async ([png, fractions]) => {
const img = await createImageBitmap(await (await fetch(`data:image/png;base64,${png}`)).blob());
const cv = new OffscreenCanvas(img.width, img.height);
const ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
return fractions.map((f) => {
const x = Math.min(img.width - 1, Math.round((img.width - 1) * f));
const [r, g, b] = ctx.getImageData(x, Math.floor(img.height / 2), 1, 1).data;
return `${Math.round(f * 100)}%:rgb(${r},${g},${b})`;
}).join(' ');
}, [png, fractions]);
}
ツールを開いて操作し、そのたびに表示された CSS、CSS.supports() の結果、プレビューの色を出力しました。Chromium は、スクリーンショットの色が画面の色の設定に左右されないよう、--force-color-profile=srgb を付けて起動しています。
開いた直後:
background-image: linear-gradient(90deg, #00c8e8 0%, #d85878 100%);
CSS.supports: true
色: 0%:rgb(0,200,232) 25%:rgb(54,171,203) 50%:rgb(108,143,175) 75%:rgb(162,116,148) 100%:rgb(216,89,121)
getComputedStyle: linear-gradient(90deg, rgb(0, 200, 232) 0%, rgb(216, 88, 120) 100%)
削除ボタン: 押せない 押せない
「色を足す」を押して、3つ目の色を #ffe066 にした:
background-image: linear-gradient(90deg, #00c8e8 0%, #ffe066 50%, #d85878 100%);
CSS.supports: true
色: 0%:rgb(0,200,231) 25%:rgb(128,212,166) 50%:rgb(255,223,102) 75%:rgb(236,157,111) 100%:rgb(216,89,120)
削除ボタン: 押せる 押せる 押せる
1つ目の位置を 70% にした(2つ目は 100%、3つ目は 50%):
background-image: linear-gradient(90deg, #ffe066 50%, #00c8e8 70%, #d85878 100%);
CSS.supports: true
色: 0%:rgb(255,224,102) 25%:rgb(255,224,102) 50%:rgb(253,223,102) 75%:rgb(36,182,214) 100%:rgb(216,89,121)
行の並び: #00c8e8 #d85878 #ffe066
開いた直後の真ん中(50%)の色は rgb(108,143,175) でした。両端の色 #00c8e8(rgb(0,200,232))と #d85878(rgb(216,88,120))を足して2で割ると rgb(108,144,176) になり、1ずつの違いはありますが、ほぼ真ん中の色です。#ffe066 を足すと、50% の位置がその色(rgb(255,224,102))とほぼ同じになりました。
1つ目の色の位置を 70% にすると、CSS では位置の順に並べ直されて #ffe066 が先頭に来ました。画面の行の並びは、足した順のままです。先頭の色の位置(50%)より左は、その色のまま塗られています。
最後にコピーのボタンを押し、クリップボードの中身を読み出しました。検証ではページを http://127.0.0.1 で開いており、Playwright でクリップボードの読み書きを許可しています。
「CSS をコピー」を押した: コピーしました
クリップボード: background-image: linear-gradient(90deg, #00c8e8 0%, #d85878 100%);
色の混ぜ方を OKLCH にすると、真ん中の色が変わる
#0000ff のような16進数で書いた色どうしは、色空間を指定しなければ sRGB で混ぜられます。CSS Color Module Level 4 の補間の色空間の節では、16進数・色の名前・rgb() などの昔からある書き方の色どうしは、互換性のために sRGB で補間しなければならない、とされています。linear-gradient(90deg in oklch, …) のように in で色空間を書くと、その色空間で混ぜます。
青(#0000ff)から黄色(#ffff00)にして、ツールの「色の混ぜ方」を切り替え、プレビューの色を読み取りました。
background-image: linear-gradient(90deg, #0000ff 0%, #ffff00 100%);
色: 0%:rgb(0,0,254) 25%:rgb(64,64,191) 50%:rgb(127,127,127) 75%:rgb(191,191,64) 100%:rgb(255,255,1)
background-image: linear-gradient(90deg in oklch, #0000ff 0%, #ffff00 100%);
色: 0%:rgb(0,2,255) 25%:rgb(0,141,247) 50%:rgb(0,207,188) 75%:rgb(20,242,105) 100%:rgb(255,255,1)
指定なし(sRGB)では、真ん中が灰色(rgb(127,127,127))になりました。OKLCH では、真ん中は青緑(rgb(0,207,188))で、青から水色、緑を通って黄色になります。CSS Color Module Level 4 の同じ節でも、sRGB で混ぜると暗くなったり灰色がかったりしやすく、灰色になるのを避けたいときは OKLCH が向く、とされています。青と黄色のように、真ん中がくすむ組み合わせのときは、ツールで両方を見比べてから選べます。





