Reactで、CSSのグラデーションを試せるツールを作る方法

​​React​CSS,Playwright,TypeScript

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() などほかの種類のグラデーションは試していません。

「色を足す」を押すと3行目に白が 50% で加わり、プレビューの真ん中が白くなる。3行目の位置のスライダーを左へドラッグすると、白の位置が 20% まで動く。角度のスライダーをドラッグすると、プレビューの向きが 90deg から 160deg へ傾く。3行目の「削除」を押すと2色に戻り、残った2行の「削除」は押せなくなる。値が変わった瞬間だけ、下の CSS の枠と、変わった行の数字や角度の表示がピンクになる
ツールを4回操作したときのプレビューと CSS の変化。カーソル、押した場所の輪、上の回数の表示は GIF 用に重ねたもの

state は角度・色の混ぜ方・色の並びだけにし、CSS の文字列はそこから作る

ツールが扱うのは、角度、色の混ぜ方、色の並び(色と位置の組)です。CSS の文字列は、これらからいつでも作れます。公式ドキュメントの、state の構造を決めるときの原則にあるとおり、ほかの state から計算できる値は state に入れないほうが、値どうしの食い違いが起きません。そこで、CSS の文字列は関数 toCss で作ることにしました。

ts
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% の順に並んだ色を渡したものです。

text
--- 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% としています)。

text
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つのときは削除しません。

ts
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: …; の形で表示し、ボタンでクリップボードにコピーできるようにしました。

tsx
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 に描き、横の決まった位置の色を読みます。

js
// 要素のスクリーンショットを撮り、横方向の決まった位置の色(縦の真ん中)を読む
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 を付けて起動しています。

text
開いた直後:
  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 でクリップボードの読み書きを許可しています。

text
「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)にして、ツールの「色の混ぜ方」を切り替え、プレビューの色を読み取りました。

text
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 が向く、とされています。青と黄色のように、真ん中がくすむ組み合わせのときは、ツールで両方を見比べてから選べます。

​Posted by 86