色を馴染ませる Web ツール「reuni」作った
絵描くとき色選びで詰まりがちだったから、カラーパレットを機械的に馴染ませる Web ツール作ってみたよ〜って話
- 🌸 サイト: https://reuni.ryu-reu.me
- 💾 ソース: https://github.com/ryu1-1uyr/reuni
reuni って何
赤・黄・緑みたいに色相が離れた色を組み合わせると、彩度や明度がバラついてケンカして見えがちじゃない? そういうのを OKLCH 色空間 で機械的に揃えて、まとまったパレットに整えるツール。
入力した色を「チェーン」っていうステップの組み合わせで加工して、Before / After を並べて確認できる感じ。
"reuni"(on) から取った造語。あと ReU が作ってるからこの名前。
使い方ざっくり
1. 入力パレットに色を入れる
入れ方は 5 通り:
- HEX 手入力(
#RRGGBB) - ネイティブのカラーピッカー
- 🎨 スポイト(EyeDropper API、対応ブラウザのみ)
- 🎲 ランダム1(それっぽい色を1個追加)
- 🎲 全ランダム置き換え(5色まるごと差し替え)
ランダム系は黄金角(137.5°)で色相をずらして L=0.55〜0.80 / C=0.10〜0.16 のレンジに収めてる。完全ランダムだと割と汚い色出るから、こうやってレンジ縛る感じ。
逆に濁った色は出にくいっていう弱点もあるけどね〜
2. チェーンを組む
「馴染ませチェーン」セクションでステップを積み重ねていく。下にあるボタンから追加できるよ:
+ 彩度統一+ 明度統一+ トーン統一(L+C 同時)+ 共通色ブレンド+ 入力色にあわせる
順番も意味あって、上から順に適用される。並べ替えは ↑↓ ボタンで。
3.「なんかいい感じにするボタン」
自分でステップ組むの面倒なときはこれ。押すたびに 5 種類のプリセットがローテーションする:
- ベージュ馴染ませ: 中央値 L/C でトーン統一 + ベージュ 15% ブレンド
- パステル化: 高明度・低彩度トーン + 暖白 4.5% ブレンド
- シック: 低明度・低彩度トーン + 黒 8% ブレンド
- ビビッド: 中明度・高彩度トーン
- なんか暗くするやつ: 彩度を極端に落として青みを混ぜて暗トーン化
押すたびに違うやつが当たるから「ガチャ」感覚で回せる。 がちゃはたのしいからね。
4. Before / After で確認
入力順 / 明度順でソート切替できる。明度順にすると差が見やすい。
モバイル表示だとチェーン編集中に Before/After が画面外に追いやられる問題あったから、After 色だけのコンパクトバーが画面上部に sticky で貼り付くようにした。pointer-coarse: で touch デバイス判定して、PC では出ない。
5. エクスポート
プレーン HEX / JSON / CSS 変数 / Tailwind v4 @theme 形式でワンクリックコピー。Tailwind v4 だと --color-rose-100: oklch(...) みたいにそのまま config に貼れる形式で出力する。
6. URL で共有
🔗 共有URLをコピー でパレット+チェーンを URL hash に詰めて共有できる。
https://reuni.ryu-reu.me/#s=eyJpbnB1dENvbG9ycyI6...hash 部分は state を URL-safe base64 にエンコードしたやつ。サーバーに送信されないからログにも残らないし、ページリロードも発生しないからシンプル。
仕様メモ
OKLCH を採用した理由
色計算を OKLCH 色空間でやるのがミソ。人間の知覚と線形に近いから、L や C を機械的に揃えても見た目で違和感出にくいらしい。
HSL でやると、例えば明度(HSL の L)を 0.7 に揃えても、黄色は眩しく見えて青は暗く見えたりして、視覚的にはバラついて感じる。OKLCH の L はそういう知覚バラツキを正規化した値だから、揃えると見た目もちゃんと揃って感じる。
ソース(参考にしたやつ)
- OKLCH vs HSL: Why Perceptual Uniformity Matters - ColorBox — わかりやすい比較
- OKLCH Color Space - Atmos Style — 数値の意味を丁寧に解説
- What are OKLCH colors? - Jakub — HSL の hue drift(色相のズレ)も指摘してる
- oklch() CSS-Tricks | — CSS の文脈での解説
5 つの馴染ませモード
| モード | 内部処理 |
|---|---|
| 彩度統一 (chroma) | 全色の C を target 値で置換 |
| 明度統一 (lightness) | 全色の L を target 値で置換 |
| トーン統一 (tone) | L と C を同時に置換(H は維持) |
| 共通色ブレンド (mix) | 全色を共通色と OKLab 空間で線形補間 |
| 入力色にあわせる (anchor) | 基準色の L/C に他色を寄せる(strength 可変) |
色サジェスト機能
「こんな色も合うかも」セクションで、既存パレットの hue ギャップを計算して埋まってない色相を 3 つ提案する。色相環を 0〜360° で見て、隣接する色相間のギャップが大きい順に上位 3 つの中点を hue として、L/C は既存パレットの中央値を使う。
チェーン機能の実装
型定義(discriminated union)
export type ChromaStep = { type: "chroma"; target: number };
export type LightnessStep = { type: "lightness"; target: number };
export type ToneStep = { type: "tone"; targetL: number; targetC: number };
export type MixStep = { type: "mix"; blendColor: HexColor; ratio: number };
export type AnchorStep = {
type: "anchor";
anchorIndex: number;
strength: number;
};
export type BlendStep =
| ChromaStep
| LightnessStep
| ToneStep
| MixStep
| AnchorStep;
export type BlendChain = BlendStep[];各モードを type で識別する discriminated union。TypeScript の switch 文でちゃんと網羅性チェック効くから安心。
各モードを純粋関数で実装
// src/core/modes/chroma.ts
export function applyChroma(palette: Palette, step: ChromaStep): Palette {
return {
...palette,
colors: palette.colors.map((hex) => {
const { l, h } = hexToOklch(hex);
return oklchToHex({ l, c: step.target, h });
}),
};
}UI に依存しない純粋関数として src/core/modes/ に切り出してる。後で CLI 化したくなっても再利用できるし、テストもしやすい。
applyStep / applyChain
export function applyStep(palette: Palette, step: BlendStep): Palette {
switch (step.type) {
case "chroma":
return applyChroma(palette, step);
case "lightness":
return applyLightness(palette, step);
case "tone":
return applyTone(palette, step);
case "mix":
return applyMix(palette, step);
case "anchor":
return applyAnchor(palette, step);
}
}
export function applyChain(palette: Palette, chain: BlendChain): Palette {
return chain.reduce((p, step) => applyStep(p, step), palette);
}reduce で順番に適用するだけ。シンプル。
Mix モードは OKLab で補間
function applyMix(palette: Palette, step: MixStep): Palette {
const blendLab = hexToOklab(step.blendColor);
return {
...palette,
colors: palette.colors.map((hex) => {
const lab = hexToOklab(hex);
const mixed = {
mode: "oklab" as const,
l: lerp(lab.l, blendLab.l, step.ratio),
a: lerp(lab.a, blendLab.a, step.ratio),
b: lerp(lab.b, blendLab.b, step.ratio),
};
return oklabToHex(mixed);
}),
};
}ブレンドは OKLCH じゃなくて OKLab でやる。OKLCH だと h(色相)が円周上の角度だから、補間方向に曖昧さが出る(時計回り?反時計回り?)。OKLab は a/b 軸の直交座標だから、線形補間で素直に補間できる。
Gamut clipping
OKLCH で計算した結果が sRGB の表示範囲外に出ることがあるから、HEX 化する直前に culori の clampChroma でクリップする:
export function oklchToHex(oklch: OklchColor): HexColor {
const culoriColor = { mode: "oklch", ...oklch };
const clamped = clampChroma(culoriColor, "oklch");
const rgb = toRgb(clamped);
return formatHex(rgb);
}これがないと、例えば純粋な OKLCH(0.7, 0.3, 30) みたいな指定が gamut 外で #NaN が出たり、変な色になったりする。
anchor モードの落とし穴
最初 strength=0 のときに「変化なし」になるはずなのに、微妙に色が変わる問題があった。原因は OKLCH ↔ HEX の往復変換で gamut 外の色が微妙に丸められること。
// 修正前: strength=0 でも変換が走る
const target = applyToOklch(color, anchor, strength);
return oklchToHex(target);
// 修正後: strength=0 なら即 return
if (step.strength === 0) return colors;strength=0 のときは早期リターンで完全にスキップ。地味だけど大事。
技術スタック
| 層 | 採用 |
|---|---|
| ビルド | Vite 8(rolldown ベース) |
| 言語 | TypeScript |
| UI | React 19 |
| 色計算 | culori(OKLCH ↔ sRGB、gamut clip) |
| スタイル | Tailwind CSS v4(@tailwindcss/vite) |
| テスト | Vitest 4(happy-dom) |
| Lint / Format | Biome |
| デプロイ | GitHub Pages + GitHub Actions + Cloudflare DNS |
Tailwind v4 で日本語フォント(Zen Maru Gothic)を当てるとこだけ書いとくと:
/* src/index.css */
@import "tailwindcss";
@theme {
--font-sans:
"Zen Maru Gothic", "Hiragino Sans", "Hiragino Kaku Gothic ProN",
"Yu Gothic", system-ui, sans-serif;
}v3 までの tailwind.config.ts じゃなくて、CSS の @theme ブロックで指定する形になった。慣れると CSS だけで完結するから良き。
おわりに
絵描くときの色選びを「機械的に揃える」だけでも、めちゃくちゃ楽になる場面ある。プロのカラリストの代わりにはならないけど、雑にパレット組んで「なんかいい感じにするボタン」連打するだけで実用に耐えるパレットになるのは作っててちょっと面白かった