カスタムセレクト

ネイティブselectを装飾しなおしたドロップダウン。開閉アニメーションと選択反映に加え、矢印キーやEnterでの操作にも対応します。

#css#js#form#select

ライブデモ

コード

HTML
<div class="stage">
  <div class="cs-card">
    <span class="cs-label" id="cs-label">お届け方法</span>

    <div class="cs-select">
      <button type="button"
              class="cs-trigger"
              id="cs-trigger"
              aria-haspopup="listbox"
              aria-expanded="false"
              aria-labelledby="cs-label cs-trigger">
        <span class="cs-trigger-text" id="cs-trigger-text">選択してください</span>
        <span class="cs-arrow" aria-hidden="true"></span>
      </button>

      <ul class="cs-list"
          id="cs-list"
          role="listbox"
          tabindex="-1"
          aria-labelledby="cs-label"
          hidden>
        <li class="cs-option" role="option" id="cs-opt-0" data-value="standard" aria-selected="false">通常配送(3〜5日)</li>
        <li class="cs-option" role="option" id="cs-opt-1" data-value="express" aria-selected="false">お急ぎ便(翌日到着)</li>
        <li class="cs-option" role="option" id="cs-opt-2" data-value="pickup" aria-selected="false">店舗受け取り</li>
        <li class="cs-option" role="option" id="cs-opt-3" data-value="locker" aria-selected="false">宅配ロッカー受け取り</li>
      </ul>
    </div>

    <p class="cs-status" id="cs-status" role="status">未選択です</p>
  </div>
</div>
CSS
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  /* 上品なティール系グラデ背景 */
  background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 50%, #cffafe 100%);
  color: #1f2937;
}

.stage { width: 100%; display: grid; place-items: center; }

.cs-card {
  width: min(340px, 92vw);
  padding: 28px 26px 24px;
  background: #fff;
  border-radius: 20px;
  box-shadow: 0 24px 56px -26px rgba(6, 95, 70, 0.32);
}

.cs-label {
  display: block;
  margin-bottom: 8px;
  font-size: 0.8rem;
  font-weight: 600;
  color: #065f46;
  letter-spacing: 0.02em;
}

.cs-select { position: relative; }

.cs-trigger {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 13px 16px;
  font-size: 0.92rem;
  font-family: inherit;
  color: #0f172a;
  background: #f0fdfa;
  border: 2px solid #a7f3d0;
  border-radius: 12px;
  cursor: pointer;
  text-align: left;
  transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.cs-trigger:hover { border-color: #6ee7b7; }
.cs-trigger:focus-visible,
.cs-trigger[aria-expanded="true"] {
  outline: none;
  border-color: #059669;
  box-shadow: 0 0 0 4px rgba(5, 150, 105, 0.16);
}

.cs-trigger-text { color: #9aa5a1; }
.cs-trigger.has-value .cs-trigger-text { color: #0f172a; font-weight: 600; }

.cs-arrow {
  flex: none;
  width: 9px;
  height: 9px;
  border-right: 2px solid #059669;
  border-bottom: 2px solid #059669;
  transform: rotate(45deg);
  transition: transform 0.22s ease;
}
.cs-trigger[aria-expanded="true"] .cs-arrow { transform: rotate(-135deg); }

.cs-list {
  position: absolute;
  z-index: 5;
  left: 0;
  right: 0;
  top: calc(100% + 8px);
  margin: 0;
  padding: 6px;
  list-style: none;
  background: #fff;
  border-radius: 14px;
  box-shadow: 0 20px 44px -16px rgba(6, 95, 70, 0.35);
  border: 1px solid #d1fae5;
  transform-origin: top center;
  opacity: 0;
  transform: translateY(-6px) scale(0.98);
  transition: opacity 0.16s ease, transform 0.16s ease;
  max-height: 220px;
  overflow-y: auto;
}
.cs-list[hidden] { display: none; }
.cs-list.is-open {
  display: block;
  opacity: 1;
  transform: translateY(0) scale(1);
}

.cs-option {
  padding: 10px 12px;
  border-radius: 9px;
  font-size: 0.88rem;
  color: #1e293b;
  cursor: pointer;
  transition: background 0.14s ease, color 0.14s ease;
}
.cs-option:hover,
.cs-option.is-active {
  background: #d1fae5;
}
.cs-option[aria-selected="true"] {
  color: #047857;
  font-weight: 700;
}
.cs-option[aria-selected="true"]::after {
  content: "✓";
  float: right;
}

.cs-status {
  margin: 14px 0 0;
  font-size: 0.76rem;
  color: #6b7d78;
}

@media (prefers-reduced-motion: reduce) {
  .cs-arrow, .cs-list, .cs-option { transition: none; }
}
JavaScript
// カスタムセレクト:listboxパターンでクリック/キーボード双方の操作に対応する
(function () {
  var trigger = document.getElementById("cs-trigger");
  var triggerText = document.getElementById("cs-trigger-text");
  var list = document.getElementById("cs-list");
  var status = document.getElementById("cs-status");
  var select = trigger ? trigger.closest(".cs-select") : null;
  if (!trigger || !triggerText || !list || !status || !select) return;

  var options = Array.prototype.slice.call(list.querySelectorAll(".cs-option"));
  if (!options.length) return;

  var activeIndex = -1;
  var selectedIndex = -1;

  function open() {
    list.hidden = false;
    list.classList.add("is-open");
    trigger.setAttribute("aria-expanded", "true");
    activeIndex = selectedIndex >= 0 ? selectedIndex : 0;
    setActive(activeIndex);
    list.focus();
  }

  function close(focusTrigger) {
    list.hidden = true;
    list.classList.remove("is-open");
    trigger.setAttribute("aria-expanded", "false");
    activeIndex = -1;
    options.forEach(function (opt) { opt.classList.remove("is-active"); });
    list.removeAttribute("aria-activedescendant");
    if (focusTrigger) trigger.focus();
  }

  function isOpen() {
    return trigger.getAttribute("aria-expanded") === "true";
  }

  function setActive(index) {
    options.forEach(function (opt, i) {
      opt.classList.toggle("is-active", i === index);
    });
    var current = options[index];
    if (current) {
      list.setAttribute("aria-activedescendant", current.id);
      current.scrollIntoView({ block: "nearest" });
    }
  }

  function selectOption(index) {
    var opt = options[index];
    if (!opt) return;
    selectedIndex = index;
    options.forEach(function (o) { o.setAttribute("aria-selected", "false"); });
    opt.setAttribute("aria-selected", "true");
    triggerText.textContent = opt.textContent;
    trigger.classList.add("has-value");
    status.textContent = opt.textContent + " を選択しました";
    close(true);
  }

  trigger.addEventListener("click", function () {
    if (isOpen()) { close(true); } else { open(); }
  });

  trigger.addEventListener("keydown", function (e) {
    if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      open();
    }
  });

  list.addEventListener("keydown", function (e) {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      activeIndex = (activeIndex + 1) % options.length;
      setActive(activeIndex);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      activeIndex = (activeIndex - 1 + options.length) % options.length;
      setActive(activeIndex);
    } else if (e.key === "Home") {
      e.preventDefault();
      activeIndex = 0;
      setActive(activeIndex);
    } else if (e.key === "End") {
      e.preventDefault();
      activeIndex = options.length - 1;
      setActive(activeIndex);
    } else if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      if (activeIndex >= 0) selectOption(activeIndex);
    } else if (e.key === "Escape") {
      e.preventDefault();
      close(true);
    } else if (e.key === "Tab") {
      close(false);
    }
  });

  options.forEach(function (opt, index) {
    opt.addEventListener("click", function () { selectOption(index); });
    opt.addEventListener("mouseenter", function () {
      activeIndex = index;
      setActive(activeIndex);
    });
  });

  document.addEventListener("click", function (e) {
    if (!select.contains(e.target) && isOpen()) close(false);
  });
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「カスタムセレクト」の効果を追加してください。

# 追加してほしい効果
カスタムセレクト(フォーム & 入力)
ネイティブselectを装飾しなおしたドロップダウン。開閉アニメーションと選択反映に加え、矢印キーやEnterでの操作にも対応します。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<div class="stage">
  <div class="cs-card">
    <span class="cs-label" id="cs-label">お届け方法</span>

    <div class="cs-select">
      <button type="button"
              class="cs-trigger"
              id="cs-trigger"
              aria-haspopup="listbox"
              aria-expanded="false"
              aria-labelledby="cs-label cs-trigger">
        <span class="cs-trigger-text" id="cs-trigger-text">選択してください</span>
        <span class="cs-arrow" aria-hidden="true"></span>
      </button>

      <ul class="cs-list"
          id="cs-list"
          role="listbox"
          tabindex="-1"
          aria-labelledby="cs-label"
          hidden>
        <li class="cs-option" role="option" id="cs-opt-0" data-value="standard" aria-selected="false">通常配送(3〜5日)</li>
        <li class="cs-option" role="option" id="cs-opt-1" data-value="express" aria-selected="false">お急ぎ便(翌日到着)</li>
        <li class="cs-option" role="option" id="cs-opt-2" data-value="pickup" aria-selected="false">店舗受け取り</li>
        <li class="cs-option" role="option" id="cs-opt-3" data-value="locker" aria-selected="false">宅配ロッカー受け取り</li>
      </ul>
    </div>

    <p class="cs-status" id="cs-status" role="status">未選択です</p>
  </div>
</div>

【CSS】
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  /* 上品なティール系グラデ背景 */
  background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 50%, #cffafe 100%);
  color: #1f2937;
}

.stage { width: 100%; display: grid; place-items: center; }

.cs-card {
  width: min(340px, 92vw);
  padding: 28px 26px 24px;
  background: #fff;
  border-radius: 20px;
  box-shadow: 0 24px 56px -26px rgba(6, 95, 70, 0.32);
}

.cs-label {
  display: block;
  margin-bottom: 8px;
  font-size: 0.8rem;
  font-weight: 600;
  color: #065f46;
  letter-spacing: 0.02em;
}

.cs-select { position: relative; }

.cs-trigger {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 13px 16px;
  font-size: 0.92rem;
  font-family: inherit;
  color: #0f172a;
  background: #f0fdfa;
  border: 2px solid #a7f3d0;
  border-radius: 12px;
  cursor: pointer;
  text-align: left;
  transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.cs-trigger:hover { border-color: #6ee7b7; }
.cs-trigger:focus-visible,
.cs-trigger[aria-expanded="true"] {
  outline: none;
  border-color: #059669;
  box-shadow: 0 0 0 4px rgba(5, 150, 105, 0.16);
}

.cs-trigger-text { color: #9aa5a1; }
.cs-trigger.has-value .cs-trigger-text { color: #0f172a; font-weight: 600; }

.cs-arrow {
  flex: none;
  width: 9px;
  height: 9px;
  border-right: 2px solid #059669;
  border-bottom: 2px solid #059669;
  transform: rotate(45deg);
  transition: transform 0.22s ease;
}
.cs-trigger[aria-expanded="true"] .cs-arrow { transform: rotate(-135deg); }

.cs-list {
  position: absolute;
  z-index: 5;
  left: 0;
  right: 0;
  top: calc(100% + 8px);
  margin: 0;
  padding: 6px;
  list-style: none;
  background: #fff;
  border-radius: 14px;
  box-shadow: 0 20px 44px -16px rgba(6, 95, 70, 0.35);
  border: 1px solid #d1fae5;
  transform-origin: top center;
  opacity: 0;
  transform: translateY(-6px) scale(0.98);
  transition: opacity 0.16s ease, transform 0.16s ease;
  max-height: 220px;
  overflow-y: auto;
}
.cs-list[hidden] { display: none; }
.cs-list.is-open {
  display: block;
  opacity: 1;
  transform: translateY(0) scale(1);
}

.cs-option {
  padding: 10px 12px;
  border-radius: 9px;
  font-size: 0.88rem;
  color: #1e293b;
  cursor: pointer;
  transition: background 0.14s ease, color 0.14s ease;
}
.cs-option:hover,
.cs-option.is-active {
  background: #d1fae5;
}
.cs-option[aria-selected="true"] {
  color: #047857;
  font-weight: 700;
}
.cs-option[aria-selected="true"]::after {
  content: "✓";
  float: right;
}

.cs-status {
  margin: 14px 0 0;
  font-size: 0.76rem;
  color: #6b7d78;
}

@media (prefers-reduced-motion: reduce) {
  .cs-arrow, .cs-list, .cs-option { transition: none; }
}

【JavaScript】
// カスタムセレクト:listboxパターンでクリック/キーボード双方の操作に対応する
(function () {
  var trigger = document.getElementById("cs-trigger");
  var triggerText = document.getElementById("cs-trigger-text");
  var list = document.getElementById("cs-list");
  var status = document.getElementById("cs-status");
  var select = trigger ? trigger.closest(".cs-select") : null;
  if (!trigger || !triggerText || !list || !status || !select) return;

  var options = Array.prototype.slice.call(list.querySelectorAll(".cs-option"));
  if (!options.length) return;

  var activeIndex = -1;
  var selectedIndex = -1;

  function open() {
    list.hidden = false;
    list.classList.add("is-open");
    trigger.setAttribute("aria-expanded", "true");
    activeIndex = selectedIndex >= 0 ? selectedIndex : 0;
    setActive(activeIndex);
    list.focus();
  }

  function close(focusTrigger) {
    list.hidden = true;
    list.classList.remove("is-open");
    trigger.setAttribute("aria-expanded", "false");
    activeIndex = -1;
    options.forEach(function (opt) { opt.classList.remove("is-active"); });
    list.removeAttribute("aria-activedescendant");
    if (focusTrigger) trigger.focus();
  }

  function isOpen() {
    return trigger.getAttribute("aria-expanded") === "true";
  }

  function setActive(index) {
    options.forEach(function (opt, i) {
      opt.classList.toggle("is-active", i === index);
    });
    var current = options[index];
    if (current) {
      list.setAttribute("aria-activedescendant", current.id);
      current.scrollIntoView({ block: "nearest" });
    }
  }

  function selectOption(index) {
    var opt = options[index];
    if (!opt) return;
    selectedIndex = index;
    options.forEach(function (o) { o.setAttribute("aria-selected", "false"); });
    opt.setAttribute("aria-selected", "true");
    triggerText.textContent = opt.textContent;
    trigger.classList.add("has-value");
    status.textContent = opt.textContent + " を選択しました";
    close(true);
  }

  trigger.addEventListener("click", function () {
    if (isOpen()) { close(true); } else { open(); }
  });

  trigger.addEventListener("keydown", function (e) {
    if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      open();
    }
  });

  list.addEventListener("keydown", function (e) {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      activeIndex = (activeIndex + 1) % options.length;
      setActive(activeIndex);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      activeIndex = (activeIndex - 1 + options.length) % options.length;
      setActive(activeIndex);
    } else if (e.key === "Home") {
      e.preventDefault();
      activeIndex = 0;
      setActive(activeIndex);
    } else if (e.key === "End") {
      e.preventDefault();
      activeIndex = options.length - 1;
      setActive(activeIndex);
    } else if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      if (activeIndex >= 0) selectOption(activeIndex);
    } else if (e.key === "Escape") {
      e.preventDefault();
      close(true);
    } else if (e.key === "Tab") {
      close(false);
    }
  });

  options.forEach(function (opt, index) {
    opt.addEventListener("click", function () { selectOption(index); });
    opt.addEventListener("mouseenter", function () {
      activeIndex = index;
      setActive(activeIndex);
    });
  });

  document.addEventListener("click", function (e) {
    if (!select.contains(e.target) && isOpen()) close(false);
  });
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。