ウェブサイトで色を選択する UI を作成する場合、<input type="color">を使用すると手軽に実装できます。しかし、この標準のカラーピッカーはブラウザごとに UI が異なり、デザインの統一が難しいという課題があります。

そこで、この記事では HTML, CSS, JavaScript を使って、カスタマイズ可能なカラーピッカーを自作する方法を紹介します。

LINE着せかえ「回るお寿司」販売中!

動作サンプル

まずは、実際に動作するサンプルです。

See the Pen Color picker using html, css, javascript by YUTSUZO (@YUTSUZO) on CodePen.

彩度(Saturation)と明度(Value)を選択するグラデーションのキャンバスと、色相(Hue)を選択するスライダーがあり、選択した色が 16 進数のカラーコードで出力されるようになっています。

サンプルのため、わかりやすいようにキャンバスの下のinputを可視化していますが、hiddenでも問題ありません。

動作サンプルのコード

上記の動作サンプルのコードは以下のようになっています。各コードの重要な部分の解説については後述します。

HTML のコード

<div class="hsv-color-picker">
  <!-- グラデーションのキャンバスと彩度(Saturation)、明度(Value)を保持する input -->
  <div class="hsv-canvas-wrapper">
    <canvas id="hsvCanvas"></canvas>
    <div id="hsvCanvasThumb" class="thumb"></div>
    <input id="hsvSaturationInput" type="text" value="0">
    <input id="hsvValueInput" type="text" value="100">
  </div>

  <!-- 色相(Hue)のスライダー -->
  <input id="hsvHueInput" type="range" name="hsv-hue" min="0" max="360" value="0">

  <!-- 16 進数のカラーコードを表示する input -->
  <input id="hexInput" type="text" name="hex" value="#FFFFFF">
</div>

CSS のコード

/* CSS 変数の定義 */
:root {
  --track-width: 100%;
  --track-height: 8px;
  --track-border-width: 0;
  --track-background: linear-gradient(to right, hsl(0deg 100% 50%), hsl(60deg 100% 50%), hsl(120deg 100% 50%), hsl(180deg 100% 50%), hsl(240deg 100% 50%), hsl(300deg 100% 50%), hsl(360deg 100% 50%));
  --track-border-color: transparent;
  --track-border-radius: 6px;

  --thumb-width: 24px;
  --thumb-height: 24px;
  --thumb-color-picker: transparent;
  --thumb-color-slider--hue: 0;
  --thumb-color-slider: hsl(var(--thumb-color-slider--hue) 100 50);
  --thumb-border-width: 2px;
  --thumb-border-color: #FFFFFF;
  --thumb-border-radius: 12px;
  --thumb-shadow: -2px 2px 2px rgb(11 0 0 / 0.2);
}

/* input[type="text"] のリセット用スタイル */
input[type="text"] {
  width: 100%;
  box-sizing: border-box;
}

/* input[type="range"] のリセット用スタイル */
input[type="range"] {
  appearance: none;
  touch-action: none;
  margin: 0;
  background: transparent;
}

input[type="range"]::-webkit-slider-thumb {
  appearance: none;
}

input[type="range"]::-moz-range-track,
input[type="range"]::-moz-range-thumb {
  border: none;
  box-sizing: border-box;
  background: transparent;
}

/* body の余白 */
body {
  padding: 20px;
}

/* ここからカラーピッカーのスタイル */
.hsv-color-picker {
  margin-inline: auto;
  width: 240px;
}

.hsv-canvas-wrapper {
  position: relative;
  margin-bottom: 8px;
  touch-action: none;
}

.hsv-canvas-wrapper canvas {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
}

.hsv-canvas-wrapper .thumb {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--thumb-width);
  height: var(--thumb-height);
  border: solid var(--thumb-border-width) var(--thumb-border-color);
  border-radius: var(--thumb-border-radius);
  background: var(--thumb-color-picker);
  box-shadow: var(--thumb-shadow);
  box-sizing: border-box;
  transform: translate(calc(var(--thumb-height) / 2 * -1), calc(var(--thumb-height) / 2 * -1));
}

/* ここからスライダーのスタイル */
input[type="range"][name="hsv-hue"] {
  width: var(--track-width);
  height: max(var(--thumb-height), var(--track-height));
}

input[type="range"][name="hsv-hue"]::-webkit-slider-runnable-track {
  height: var(--track-height);
  border: solid var(--track-border-width) var(--track-border-color);
  border-radius: var(--track-border-radius);
  background-image: var(--track-background);
}

input[type="range"][name="hsv-hue"]::-webkit-slider-thumb {
  margin-top: calc((var(--thumb-height) - var(--track-height)) / 2 * -1);
  width: var(--thumb-width);
  height: var(--thumb-height);
  border: solid var(--thumb-border-width) var(--thumb-border-color);
  border-radius: var(--thumb-border-radius);
  background: var(--thumb-color-slider);
  box-shadow: var(--thumb-shadow);
}

input[type="range"][name="hsv-hue"]::-moz-range-track {
  height: var(--track-height);
  border: solid var(--track-border-width) var(--track-border-color);
  border-radius: var(--track-border-radius);
  background-image: var(--track-background);
}

input[type="range"][name="hsv-hue"]::-moz-range-thumb {
  width: var(--thumb-width);
  height: var(--thumb-height);
  border: solid var(--thumb-border-width) var(--thumb-border-color);
  border-radius: var(--thumb-border-radius);
  background: var(--thumb-color-slider);
  box-shadow: var(--thumb-shadow);
}

JavaScript のコード

window.addEventListener("DOMContentLoaded", function () {
  // :root 疑似クラスを取得
  const root = document.querySelector(":root");

  // hsvCanvas のサイズ取得
  const pickerWidth = hsvCanvas.getBoundingClientRect().width;
  const pickerHeight = hsvCanvas.getBoundingClientRect().height;

  // 初期設定
  hsvCanvasThumb.style.left = (pickerWidth * hsvSaturationInput.value / 100)  + "px";
  hsvCanvasThumb.style.top = (pickerWidth * (100 - hsvValueInput.value) / 100)  + "px";
  drawPickerGradient(hsvHueInput.value);
  updateColors();

  // スライダーのイベントリスナー登録
  hsvHueInput.addEventListener("input", function() {
    drawPickerGradient(this.value);
    updateColors();
  });

  // hsvCanvas, hsvCanvasThumb のイベントリスナー登録
  hsvCanvas.addEventListener("pointermove", handlePickerPointerMove);
  hsvCanvas.addEventListener("pointerdown", handlePickerPointerMove);
  hsvCanvasThumb.addEventListener("pointerdown", function(event) {
    // ポインターイベントのキャプチャターゲットを hsvCanvas に設定
    hsvCanvas.setPointerCapture(event.pointerId);
  });

  /**
   * カラーピッカーの canvas にグラデーションを描画
   * @param {number} h 描画するグラデーションの色相(Hue)
   */
  function drawPickerGradient(h) {
    const canvas = document.getElementById("hsvCanvas");
    const ctx = canvas.getContext("2d");
    const width = canvas.width;
    const height = canvas.height;

    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        let s = (x / width) * 100;
        let v = 100 - (y / height) * 100;
        const rgb = hsvToRgb(h, s, v);
        ctx.fillStyle = "rgb(" + rgb.r + ", " + rgb.g + ", " + rgb.b + ")";
        ctx.fillRect(x, y, 1, 1);
      }
    }
  }

  /**
   * カラーピッカーの色情報を更新
   */
  function updateColors() {
    // HSV から変換
    const hex = hsvToHex(hsvHueInput.value, hsvSaturationInput.value, hsvValueInput.value);

    // thumb のカラーを変更
    root.style.setProperty("--thumb-color-slider--hue", hsvHueInput.value);
    root.style.setProperty("--thumb-color-picker", "#" + hex);

    // hexInput の値を更新
    hexInput.value = "#" + hex;
  }

  /**
   * キャンバス上でポインターを動かしたときの処理
   * @param {object} PointerEvent
   */
  function handlePickerPointerMove(event) {
    // マウスの左ボタン押下中もしくはタップ中かどうか
    if(event.buttons == 1) {
      // ポインターイベントのキャプチャターゲットを hsvCanvas に設定
      hsvCanvas.setPointerCapture(event.pointerId);

      // ポインターの位置を取得(最小: 0, 最大: hsvCanvas のサイズ)
      let top = Math.max(0, Math.min(event.offsetY, pickerHeight));
      let left = Math.max(0, Math.min(event.offsetX, pickerWidth));

      // hsvCanvasThumb の位置を変更
      hsvCanvasThumb.style.top = top + "px";
      hsvCanvasThumb.style.left = left + "px";

      // ポインターの位置(%)を計算
      const hsvSaturation = left / pickerWidth * 100;
      const hsvValue = (1 - top / pickerHeight) * 100;

      // HSV input の値を更新
      hsvSaturationInput.value = hsvSaturation;
      hsvValueInput.value = hsvValue;

      updateColors();
    }
  }
});

/**
 * HSV 形式を 16進数形式に変換
 * @param {number} h 色相(Hue)
 * @param {number} s 彩度(Saturation)
 * @param {number} v 明度(Value)
 */
function hsvToHex(h, s, v) {
  s /= 100;
  v /= 100;

  let c = v * s;
  let x = c * (1 - Math.abs((h / 60) % 2 - 1));
  let m = v - c;

  let r, g, b;

  if (h < 60) {
    r = c, g = x, b = 0;
  } else if (h < 120) {
    r = x, g = c, b = 0;
  } else if (h < 180) {
    r = 0, g = c, b = x;
  } else if (h < 240) {
    r = 0, g = x, b = c;
  } else if (h < 300) {
    r = x, g = 0, b = c;
  } else {
    r = c, g = 0, b = x;
  }

  r = Math.round((r + m) * 255);
  g = Math.round((g + m) * 255);
  b = Math.round((b + m) * 255);

  return (r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0')).toUpperCase();
}

/**
 * HSV 形式を RGB 形式に変換
 * @param {number} h 色相(Hue)
 * @param {number} s 彩度(Saturation)
 * @param {number} v 明度(Value)
 */
function hsvToRgb(h, s, v) {
  s /= 100;
  v /= 100;

  let r, g, b;

  const i = Math.floor(h / 60);
  const f = (h / 60) - i;
  const p = v * (1 - s);
  const q = v * (1 - s * f);
  const t = v * (1 - s * (1 - f));

  switch (i % 6) {
    case 0: r = v, g = t, b = p; break;
    case 1: r = q, g = v, b = p; break;
    case 2: r = p, g = v, b = t; break;
    case 3: r = p, g = q, b = v; break;
    case 4: r = t, g = p, b = v; break;
    case 5: r = v, g = p, b = q; break;
  }

  return {
    r: Math.round(r * 255),
    g: Math.round(g * 255),
    b: Math.round(b * 255)
  };
}

動作サンプルのコード解説

ここからは、動作サンプルのコードの解説です。すべて解説すると非常に長くなってしまうので、重要な部分をピックアップして解説します。

HTML のコード解説

キャンバス部分

まずは、HTML のキャンバス部分についての解説です。グラデーションのキャンバスと、その中にあるツマミが含まれます。

  <div class="hsv-canvas-wrapper">
    <canvas id="hsvCanvas"></canvas>
    <div id="hsvCanvasThumb" class="thumb"></div>
    <input id="hsvSaturationInput" type="text" value="0">
    <input id="hsvValueInput" type="text" value="100">
  </div>
  • .hsv-canvas-wrapper :
    キャンバスのラッパー。#hsvCanvasThumbの移動範囲を制限するためのもの。
  • #hsvCanvas:
    グラデーションを描画するためのcanvas。CSS では HSV の色空間が使用できないのでcanvasでグラデーションを描画する。なぜ HSV を使う必要があるのかというと、その方が計算が楽なため。
  • #hsvCanvasThumb:
    キャンバス上のツマミ。どこが選択されているかをわかりやすくするための目印。
  • #hsvSaturationInput :
    彩度を保持しておくためのもの。#hsvCanvasThumbの X 座標を%で入れておく(左が 0% で、右が 100%)。
  • #hsvValueInput :
    明度を保持しておくためのもの。#hsvCanvasThumbの Y 座標を%で入れておく(上が 100% で、下が 0%)。

スライダー部分

HTML のスライダー部分です。

  <input id="hsvHueInput" type="range" name="hsv-hue" min="0" max="360" value="0">
  • #hsvHueInput :
    色相を選択するスライダー。色相環の角度をこのスライダーで設定する。最小値は0、最大値は360

色相環についてはこちらのページがわかりやすいです。
<hue> - CSS: カスケーディングスタイルシート | MDN

CSS のコード解説

続いて、CSS の解説です。

CSS 変数の定義部分

CSS は、スライダーのクロスブラウザ対応やツマミの見た目のスタイリングに共通で使う値が多いので、変数を使って定義しています。

:root {
  --track-width: 100%;
  --track-height: 8px;
  --track-background: linear-gradient(to right, hsl(0deg 100% 50%), hsl(60deg 100% 50%), hsl(120deg 100% 50%), hsl(180deg 100% 50%), hsl(240deg 100% 50%), hsl(300deg 100% 50%), hsl(360deg 100% 50%));
  --track-border-width: 0;
  --track-border-color: transparent;
  --track-border-radius: 6px;

  --thumb-width: 24px;
  --thumb-height: 24px;
  --thumb-color-picker: transparent;
  --thumb-color-slider--hue: 0;
  --thumb-color-slider: hsl(var(--thumb-color-slider--hue) 100 50);
  --thumb-border-width: 2px;
  --thumb-border-color: #FFFFFF;
  --thumb-border-radius: 12px;
  --thumb-shadow: -2px 2px 2px rgb(11 0 0 / 0.2);
}

特に重要なのは、ハイライト表示した箇所です。

  • --track-background :
    スライダーの背景のグラデーション。hsl()関数を使用して、色相環をグルっと回った色を線形グラデーションとして描画(他のところでは HSV を使ってるのに、ここだけ HSL なのはご愛嬌ということで…。 Google のカラーピッカーでも同じようになっていたので、おそらくこれでも問題ないと思います)。
  • --thumb-color-slider--hue :
    スライダーで選択した色相環の角度を CSS で使えるようにする変数。0から360の値を JavaScript でこの変数にセット。
  • --thumb-color-slider :
    スライダーのツマミの色を指定する変数。hsl()関数に--thumb-color-slider--hueの値を指定することで、選択した値によってツマミの色が変わるようにする。

カラーピッカー全体

カラーピッカー全体に関わるスタイルの部分です。

.hsv-color-picker {
  margin-inline: auto;
  width: 240px;
}
  • .hsv-color-picker :
    カラーピッカー全体(スライダーも含む)を囲むクラス。widthでカラーピッカー全体の幅を設定。

キャンバス部分

グラデーションのキャンバスと、その中のツマミに関するスタイルです。

.hsv-canvas-wrapper {
  position: relative;
  margin-bottom: 8px;
  touch-action: none;
}

.hsv-canvas-wrapper canvas {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
}

.hsv-canvas-wrapper .thumb {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--thumb-width);
  height: var(--thumb-height);
  border: solid var(--thumb-border-width) var(--thumb-border-color);
  border-radius: var(--thumb-border-radius);
  background: var(--thumb-color-picker);
  box-shadow: var(--thumb-shadow);
  box-sizing: border-box;
  transform: translate(calc(var(--thumb-height) / 2 * -1), calc(var(--thumb-height) / 2 * -1));
}
  • .hsv-canvas-wrapper :
    キャンバスのラッパー。ツマミがabsoluteのため、ここにrelativeを指定。
    touch-action: none;を指定することで、タッチデバイスでのドラッグ中に画面がスクロールしないようにする
  • .hsv-canvas-wrapper canvas :
    グラデーションを描画するcanvas本体。aspect-ratio: 1 / 1;を指定して、正方形になるようにする。正方形じゃなくてもいい場合は、ここの比率を変える。
  • .hsv-canvas-wrapper .thumb:
    キャンバス上のツマミ。見た目がスライダーのツマミと同じになるようにスタイリング。
    キャンバス上を動かすため、position: absolute;を指定。
    中心が選択位置になるように、translateで位置を縦横半分ずつズラす

スライダー部分

スライダー部分のスタイルです。

input[type="range"][name="hsv-hue"]::-webkit-slider-runnable-track {
  height: var(--track-height);
  border: solid var(--track-border-width) var(--track-border-color);
  border-radius: var(--track-border-radius);
  background-image: var(--track-background);
}

input[type="range"][name="hsv-hue"]::-webkit-slider-thumb {
  margin-top: calc((var(--thumb-height) - var(--track-height)) / 2 * -1);
  width: var(--thumb-width);
  height: var(--thumb-height);
  border: solid var(--thumb-border-width) var(--thumb-border-color);
  border-radius: var(--thumb-border-radius);
  background: var(--thumb-color-slider);
  box-shadow: var(--thumb-shadow);
}

input[type="range"][name="hsv-hue"]::-moz-range-track {
  height: var(--track-height);
  border: solid var(--track-border-width) var(--track-border-color);
  border-radius: var(--track-border-radius);
  background-image: var(--track-background);
}

input[type="range"][name="hsv-hue"]::-moz-range-thumb {
  width: var(--thumb-width);
  height: var(--thumb-height);
  border: solid var(--thumb-border-width) var(--thumb-border-color);
  border-radius: var(--thumb-border-radius);
  background: var(--thumb-color-slider);
  box-shadow: var(--thumb-shadow);
}

WebKit 系と Mozilla 系に分かれているだけで、書いてあることはほとんど同じです。分けてある理由は、異なるベンダープレフィックス付きの疑似要素はまとめて記述ができないためです。

WebKit 系では、スライダーのスタイルをリセットするとツマミの位置が下にズレるので、それを修正するための行を追加しています。その辺りの挙動については、こちらの記事で詳しく解説しているので、よかったらご参照ください。
<input type="range">スライダーの見た目をCSSでカスタマイズする

JavaScript のコードの解説

そして、最後に JavaScript のコード解説です。

初期設定の部分

読み込み時の初期設定を行う部分です。

  // hsvCanvas のサイズ取得
  const pickerWidth = hsvCanvas.getBoundingClientRect().width;
  const pickerHeight = hsvCanvas.getBoundingClientRect().height;

  // 初期設定
  hsvCanvasThumb.style.left = (pickerWidth * hsvSaturationInput.value / 100)  + "px";
  hsvCanvasThumb.style.top = (pickerWidth * (100 - hsvValueInput.value) / 100)  + "px";
  drawPickerGradient(hsvHueInput.value);
  updateColors();
  • hsvCanvas のサイズ取得 :
    キャンバスのサイズを取得。
  • 初期設定 :
    ツマミの初期位置やグラデーションの描画、カラーピッカー内の色の情報などの初期設定を行う。これにより、リロード時にinputの値が残っていた場合は、その値が反映される。

スライダーのイベントリスナー登録

スライダーを動かした時のイベントリスナー登録です。

  // スライダーのイベントリスナー登録
  hsvHueInput.addEventListener("input", function() {
    drawPickerGradient(this.value);
    updateColors();
  });

グラデーションの再描画と、カラーピッカー内の色の情報の更新を行います。

ドラッグ時のイベントリスナー登録

キャンバス上をドラッグしたときのイベントリスナー登録です。

  // hsvCanvas, hsvCanvasThumb のイベントリスナー登録
  hsvCanvas.addEventListener("pointermove", handlePickerPointerMove);
  hsvCanvas.addEventListener("pointerdown", handlePickerPointerMove);
  hsvCanvasThumb.addEventListener("pointerdown", function(event) {
    // ポインターイベントのキャプチャターゲットを hsvCanvas に設定
    hsvCanvas.setPointerCapture(event.pointerId);
  });

ドラッグイベントはキャンバスで拾うようになっています(ツマミはそれに合わせて動くだけ)。キャンバスをクリックした際にもその位置が選択されるようにするため、pointerdownにもイベントリスナーを登録しています。また、ここではツマミのクリックからドラッグを開始できる処理も行っています。

  • hsvCanvas.setPointerCapture(event.pointerId); :
    ツマミ自体をクリック(もしくはタップ)した際にもドラッグが開始出来るように、ツマミのpointerdownイベントが発生したときにポインターイベントのキャプチャターゲットをキャンバスに変更

ドラッグ中の処理を行う関数

少しコード内の順番が前後しますが、ドラッグ中の処理を行う関数の解説です。

  /**
   * キャンバス上でポインターを動かしたときの処理
   * @param {object} PointerEvent
   */
  function handlePickerPointerMove(event) {
    // マウスの左ボタン押下中もしくはタップ中かどうか
    if(event.buttons == 1) {
      // ポインターイベントのキャプチャターゲットを hsvCanvas に設定
      hsvCanvas.setPointerCapture(event.pointerId);

      // ポインターの位置を取得(最小: 0, 最大: hsvCanvas のサイズ)
      let top = Math.max(0, Math.min(event.offsetY, pickerHeight));
      let left = Math.max(0, Math.min(event.offsetX, pickerWidth));

      // hsvCanvasThumb の位置を変更
      hsvCanvasThumb.style.top = top + "px";
      hsvCanvasThumb.style.left = left + "px";

      // ポインターの位置(%)を計算
      const hsvSaturation = left / pickerWidth * 100;
      const hsvValue = (1 - top / pickerHeight) * 100;

      // HSV input の値を更新
      hsvSaturationInput.value = hsvSaturation;
      hsvValueInput.value = hsvValue;

      updateColors();
    }
  }
  • if(event.buttons == 1) :
    マウスの左ボタンを押下中もしくはタップ中のみ処理を行うようにする。
  • hsvCanvas.setPointerCapture(event.pointerId); :
    ツマミの時と用途が違い、こちらはドラッグ中にキャンバスからポインターが外れた場合でもイベントを拾い続けるようにするもの。これにより、ドラッグ中にキャンバスの外に出てもドラッグ状態が継続する。
  • ポインターの位置を取得 :
    ポインターの位置がキャンバス上のどこにあるかをpxで取得。
  • ポインターの位置(%)を計算 :
    上で取得したpxを元に、ポインターの位置を % で計算。HSV 形式の色空間では、彩度と明度の指定にこの数値がそのまま使用できるcanvasを使用したのはこのため)。

グラデーションを描画する関数

canvasにグラデーションを描画する関数です。

  /**
   * カラーピッカーの canvas にグラデーションを描画
   * @param {number} h 描画するグラデーションの色相(Hue)
   */
  function drawPickerGradient(h) {
    const canvas = document.getElementById("hsvCanvas");
    const ctx = canvas.getContext("2d");
    const width = canvas.width;
    const height = canvas.height;

    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        let s = (x / width) * 100;
        let v = 100 - (y / height) * 100;
        const rgb = hsvToRgb(h, s, v);
        ctx.fillStyle = "rgb(" + rgb.r + ", " + rgb.g + ", " + rgb.b + ")";
        ctx.fillRect(x, y, 1, 1);
      }
    }
  }

スライダーで選択した色相を元にグラデーションを描画します。ここの処理や、途中の HSV から RGB に変換するhsvToRgb()などは、ChatGPT にお願いしたら書いてくれました。

色情報を更新する関数

  /**
   * カラーピッカーの色情報を更新
   */
  function updateColors() {
    // HSV から変換
    const hex = hsvToHex(hsvHueInput.value, hsvSaturationInput.value, hsvValueInput.value);

    // thumb のカラーを変更
    root.style.setProperty("--thumb-color-slider--hue", hsvHueInput.value);
    root.style.setProperty("--thumb-color-picker", "#" + hex);

    // hexInput の値を更新
    hexInput.value = "#" + hex;
  }

カラーピッカー内の色情報を更新する関数です。ここで、16 進数への変換や、ツマミの色の変更、最終的なカラーコードの出力を行っています。ここの途中に出てくるhsvToHex()も ChatGPT が書いてくれました。

以上で、コードについての解説は終わりです。

終わりに

HTML, CSS, JavaScript を使って、オリジナルのカラーピッカーを作成する方法を紹介しました。ブラウザ標準のカラーピッカーではデザインを統一することが困難ですが、自作することで自由にカスタマイズすることができます。

さらに、カラーパレット機能を追加したり、16 進数以外に RGB や HSV, HSL の値を表示できるように拡張したりすることで、より高度なカラーピッカーを実装できます。ぜひ、今回紹介したコードを活用してみてください!

参考

タグ: