スプリットボタン

主アクションボタンと▼ボタンを分離したスプリットボタンです。▼側でエクスポート形式を選ぶと既定の形式が切り替わり、主ボタンはワンクリックでその形式のまま実行します。

#css#js#dropdown#button

ライブデモ

コード

HTML
<!-- スプリットボタン: 主アクションボタン(即実行)と▼ボタン(書き出し形式の選択)を分離したボタン -->
<div class="dsb-stage">
  <div class="dsb-group" role="group" aria-label="エクスポート">
    <button type="button" class="dsb-main" id="dsbMain">
      <span class="dsb-main__icon" aria-hidden="true">⬇</span>
      <span class="dsb-main__label" id="dsbMainLabel">PDFでエクスポート</span>
    </button>
    <button type="button" class="dsb-toggle" id="dsbToggle" aria-haspopup="true" aria-expanded="false" aria-controls="dsbMenu" aria-label="エクスポート形式を選択">
      <span class="dsb-caret" aria-hidden="true">▾</span>
    </button>
    <ul class="dsb-menu" id="dsbMenu" role="menu" aria-labelledby="dsbToggle" hidden>
      <li role="none">
        <button type="button" class="dsb-item" role="menuitemradio" aria-checked="true" data-format="PDF">
          <span class="dsb-item__icon" aria-hidden="true">📄</span>
          <span class="dsb-item__label">PDF ドキュメント</span>
          <span class="dsb-item__check" aria-hidden="true">✓</span>
        </button>
      </li>
      <li role="none">
        <button type="button" class="dsb-item" role="menuitemradio" aria-checked="false" data-format="CSV">
          <span class="dsb-item__icon" aria-hidden="true">📊</span>
          <span class="dsb-item__label">CSV スプレッドシート</span>
          <span class="dsb-item__check" aria-hidden="true">✓</span>
        </button>
      </li>
      <li role="none">
        <button type="button" class="dsb-item" role="menuitemradio" aria-checked="false" data-format="PNG">
          <span class="dsb-item__icon" aria-hidden="true">🖼</span>
          <span class="dsb-item__label">PNG 画像</span>
          <span class="dsb-item__check" aria-hidden="true">✓</span>
        </button>
      </li>
      <li role="none">
        <button type="button" class="dsb-item" role="menuitemradio" aria-checked="false" data-format="JSON">
          <span class="dsb-item__icon" aria-hidden="true">{ }</span>
          <span class="dsb-item__label">JSON データ</span>
          <span class="dsb-item__check" aria-hidden="true">✓</span>
        </button>
      </li>
    </ul>
  </div>
  <p class="dsb-hint" id="dsbStatus" aria-live="polite">▾ で形式を切り替え、主ボタンは選択中の形式で即実行します。</p>
</div>
CSS
/* 深いチャコール背景にアンバーの主/副ボタンを組み合わせたスプリットボタン */
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 48px 24px;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  background:
    radial-gradient(circle at 20% 15%, rgba(245, 158, 11, .12) 0%, transparent 45%),
    radial-gradient(circle at 80% 85%, rgba(217, 119, 6, .10) 0%, transparent 45%),
    #16181d;
  color: #f4ede3;
}

.dsb-stage { width: 100%; max-width: 420px; display: grid; gap: 18px; justify-items: center; }

.dsb-group {
  position: relative;
  display: inline-flex;
  border-radius: 12px;
  box-shadow: 0 20px 44px -22px rgba(0, 0, 0, .55);
}

.dsb-main {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 14px 22px;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  color: #1c1406;
  background: linear-gradient(135deg, #fbbf24, #f59e0b);
  border: none;
  border-radius: 12px 0 0 12px;
  cursor: pointer;
  transition: filter .18s ease, transform .12s ease;
}
.dsb-main:hover { filter: brightness(1.06); }
.dsb-main:active { transform: scale(.98); }
.dsb-main:focus-visible { outline: 2px solid #fde68a; outline-offset: 2px; }
.dsb-main.is-pulsing { animation: dsbPulse .5s ease; }

@keyframes dsbPulse {
  0% { box-shadow: 0 0 0 0 rgba(251, 191, 36, .55); }
  100% { box-shadow: 0 0 0 14px rgba(251, 191, 36, 0); }
}

.dsb-toggle {
  display: grid;
  place-items: center;
  width: 40px;
  border: none;
  border-left: 1px solid rgba(0, 0, 0, .16);
  border-radius: 0 12px 12px 0;
  background: linear-gradient(135deg, #f59e0b, #d97706);
  color: #1c1406;
  cursor: pointer;
  transition: filter .18s ease;
}
.dsb-toggle:hover { filter: brightness(1.06); }
.dsb-toggle:focus-visible { outline: 2px solid #fde68a; outline-offset: 2px; }
.dsb-toggle[aria-expanded="true"] { filter: brightness(.94); }
.dsb-caret { font-size: 11px; display: inline-block; transition: transform .22s cubic-bezier(.22, 1, .36, 1); }
.dsb-toggle[aria-expanded="true"] .dsb-caret { transform: rotate(180deg); }

.dsb-menu {
  position: absolute;
  z-index: 10;
  top: calc(100% + 10px);
  right: 0;
  min-width: 230px;
  margin: 0;
  padding: 6px;
  list-style: none;
  background: #23262e;
  border: 1px solid #33363f;
  border-radius: 14px;
  box-shadow: 0 26px 54px -18px rgba(0, 0, 0, .6);
  transform-origin: top right;
  opacity: 0;
  transform: translateY(-6px) scale(.97);
  transition: opacity .16s ease, transform .16s ease;
}
.dsb-menu[hidden] { display: none; }
.dsb-menu.is-open { opacity: 1; transform: translateY(0) scale(1); }

.dsb-item {
  width: 100%;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px;
  font: inherit;
  font-size: 13.5px;
  color: #e7e2d6;
  background: none;
  border: none;
  border-radius: 9px;
  text-align: left;
  cursor: pointer;
  transition: background .14s ease;
}
.dsb-item:hover,
.dsb-item.is-active { background: #2f333c; }
.dsb-item:focus-visible { outline: 2px solid #fbbf24; outline-offset: -2px; }
.dsb-item__icon { flex: none; width: 20px; text-align: center; font-size: 13px; color: #b7a98a; }
.dsb-item__label { flex: 1; }
.dsb-item__check { flex: none; opacity: 0; color: #f59e0b; font-size: 12px; }
.dsb-item[aria-checked="true"] .dsb-item__check { opacity: 1; }
.dsb-item[aria-checked="true"] .dsb-item__label { font-weight: 700; color: #fff; }

.dsb-hint {
  margin: 0;
  font-size: 12.5px;
  color: #9a9488;
  text-align: center;
  max-width: 320px;
  line-height: 1.7;
  min-height: 1.4em;
}

@media (prefers-reduced-motion: reduce) {
  .dsb-menu, .dsb-caret { transition: none; }
  .dsb-main.is-pulsing { animation: none; }
}
JavaScript
// スプリットボタン: ▼側で書き出し形式を選ぶと既定形式が変わり、主ボタンはその形式のまま実行する
(() => {
  const main = document.getElementById('dsbMain');
  const mainLabel = document.getElementById('dsbMainLabel');
  const toggle = document.getElementById('dsbToggle');
  const menu = document.getElementById('dsbMenu');
  const status = document.getElementById('dsbStatus');
  const group = toggle ? toggle.closest('.dsb-group') : null;
  if (!main || !mainLabel || !toggle || !menu || !status || !group) return;

  const items = Array.from(menu.querySelectorAll('.dsb-item'));
  if (!items.length) return;

  const isOpen = () => toggle.getAttribute('aria-expanded') === 'true';

  const setActive = (index) => {
    items.forEach((it, i) => it.classList.toggle('is-active', i === index));
  };

  const open = () => {
    menu.hidden = false;
    void menu.offsetHeight; // 強制リフローでトランジションを確実に発火させる
    menu.classList.add('is-open');
    toggle.setAttribute('aria-expanded', 'true');
    const checkedIndex = items.findIndex((it) => it.getAttribute('aria-checked') === 'true');
    const idx = checkedIndex >= 0 ? checkedIndex : 0;
    setActive(idx);
    items[idx].focus();
  };

  const close = (focusToggle) => {
    menu.hidden = true;
    menu.classList.remove('is-open');
    toggle.setAttribute('aria-expanded', 'false');
    setActive(-1);
    if (focusToggle) toggle.focus();
  };

  toggle.addEventListener('click', () => {
    if (isOpen()) close(false); else open();
  });

  toggle.addEventListener('keydown', (e) => {
    if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      open();
    }
  });

  const focusByIndex = (index) => {
    const len = items.length;
    const i = ((index % len) + len) % len;
    setActive(i);
    items[i].focus();
  };

  const selectFormat = (index) => {
    const item = items[index];
    if (!item) return;
    items.forEach((it) => it.setAttribute('aria-checked', 'false'));
    item.setAttribute('aria-checked', 'true');
    const fmt = item.dataset.format || '';
    const label = item.querySelector('.dsb-item__label');
    mainLabel.textContent = `${fmt}でエクスポート`;
    status.textContent = `既定の形式を${label ? label.textContent : fmt}に変更しました`;
    close(true);
  };

  menu.addEventListener('keydown', (e) => {
    const current = items.indexOf(document.activeElement);
    switch (e.key) {
      case 'ArrowDown': e.preventDefault(); focusByIndex(current + 1); break;
      case 'ArrowUp': e.preventDefault(); focusByIndex(current - 1); break;
      case 'Home': e.preventDefault(); focusByIndex(0); break;
      case 'End': e.preventDefault(); focusByIndex(items.length - 1); break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (current >= 0) selectFormat(current);
        break;
      case 'Escape': e.preventDefault(); close(true); break;
      case 'Tab': close(false); break;
      default: break;
    }
  });

  items.forEach((item, index) => {
    item.addEventListener('click', () => selectFormat(index));
    item.addEventListener('mouseenter', () => setActive(index));
  });

  main.addEventListener('click', () => {
    if (isOpen()) close(false);
    main.classList.remove('is-pulsing');
    void main.offsetWidth; // 強制リフローで連続クリックでもアニメを再発火させる
    main.classList.add('is-pulsing');
    status.textContent = `${mainLabel.textContent}を実行しました`;
  });
  main.addEventListener('animationend', () => main.classList.remove('is-pulsing'));

  document.addEventListener('click', (e) => {
    if (isOpen() && !group.contains(e.target)) close(false);
  });
})();

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

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

# 追加してほしい効果
スプリットボタン(ツールチップ & メニュー)
主アクションボタンと▼ボタンを分離したスプリットボタンです。▼側でエクスポート形式を選ぶと既定の形式が切り替わり、主ボタンはワンクリックでその形式のまま実行します。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- スプリットボタン: 主アクションボタン(即実行)と▼ボタン(書き出し形式の選択)を分離したボタン -->
<div class="dsb-stage">
  <div class="dsb-group" role="group" aria-label="エクスポート">
    <button type="button" class="dsb-main" id="dsbMain">
      <span class="dsb-main__icon" aria-hidden="true">⬇</span>
      <span class="dsb-main__label" id="dsbMainLabel">PDFでエクスポート</span>
    </button>
    <button type="button" class="dsb-toggle" id="dsbToggle" aria-haspopup="true" aria-expanded="false" aria-controls="dsbMenu" aria-label="エクスポート形式を選択">
      <span class="dsb-caret" aria-hidden="true">▾</span>
    </button>
    <ul class="dsb-menu" id="dsbMenu" role="menu" aria-labelledby="dsbToggle" hidden>
      <li role="none">
        <button type="button" class="dsb-item" role="menuitemradio" aria-checked="true" data-format="PDF">
          <span class="dsb-item__icon" aria-hidden="true">📄</span>
          <span class="dsb-item__label">PDF ドキュメント</span>
          <span class="dsb-item__check" aria-hidden="true">✓</span>
        </button>
      </li>
      <li role="none">
        <button type="button" class="dsb-item" role="menuitemradio" aria-checked="false" data-format="CSV">
          <span class="dsb-item__icon" aria-hidden="true">📊</span>
          <span class="dsb-item__label">CSV スプレッドシート</span>
          <span class="dsb-item__check" aria-hidden="true">✓</span>
        </button>
      </li>
      <li role="none">
        <button type="button" class="dsb-item" role="menuitemradio" aria-checked="false" data-format="PNG">
          <span class="dsb-item__icon" aria-hidden="true">🖼</span>
          <span class="dsb-item__label">PNG 画像</span>
          <span class="dsb-item__check" aria-hidden="true">✓</span>
        </button>
      </li>
      <li role="none">
        <button type="button" class="dsb-item" role="menuitemradio" aria-checked="false" data-format="JSON">
          <span class="dsb-item__icon" aria-hidden="true">{ }</span>
          <span class="dsb-item__label">JSON データ</span>
          <span class="dsb-item__check" aria-hidden="true">✓</span>
        </button>
      </li>
    </ul>
  </div>
  <p class="dsb-hint" id="dsbStatus" aria-live="polite">▾ で形式を切り替え、主ボタンは選択中の形式で即実行します。</p>
</div>

【CSS】
/* 深いチャコール背景にアンバーの主/副ボタンを組み合わせたスプリットボタン */
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 48px 24px;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  background:
    radial-gradient(circle at 20% 15%, rgba(245, 158, 11, .12) 0%, transparent 45%),
    radial-gradient(circle at 80% 85%, rgba(217, 119, 6, .10) 0%, transparent 45%),
    #16181d;
  color: #f4ede3;
}

.dsb-stage { width: 100%; max-width: 420px; display: grid; gap: 18px; justify-items: center; }

.dsb-group {
  position: relative;
  display: inline-flex;
  border-radius: 12px;
  box-shadow: 0 20px 44px -22px rgba(0, 0, 0, .55);
}

.dsb-main {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 14px 22px;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  color: #1c1406;
  background: linear-gradient(135deg, #fbbf24, #f59e0b);
  border: none;
  border-radius: 12px 0 0 12px;
  cursor: pointer;
  transition: filter .18s ease, transform .12s ease;
}
.dsb-main:hover { filter: brightness(1.06); }
.dsb-main:active { transform: scale(.98); }
.dsb-main:focus-visible { outline: 2px solid #fde68a; outline-offset: 2px; }
.dsb-main.is-pulsing { animation: dsbPulse .5s ease; }

@keyframes dsbPulse {
  0% { box-shadow: 0 0 0 0 rgba(251, 191, 36, .55); }
  100% { box-shadow: 0 0 0 14px rgba(251, 191, 36, 0); }
}

.dsb-toggle {
  display: grid;
  place-items: center;
  width: 40px;
  border: none;
  border-left: 1px solid rgba(0, 0, 0, .16);
  border-radius: 0 12px 12px 0;
  background: linear-gradient(135deg, #f59e0b, #d97706);
  color: #1c1406;
  cursor: pointer;
  transition: filter .18s ease;
}
.dsb-toggle:hover { filter: brightness(1.06); }
.dsb-toggle:focus-visible { outline: 2px solid #fde68a; outline-offset: 2px; }
.dsb-toggle[aria-expanded="true"] { filter: brightness(.94); }
.dsb-caret { font-size: 11px; display: inline-block; transition: transform .22s cubic-bezier(.22, 1, .36, 1); }
.dsb-toggle[aria-expanded="true"] .dsb-caret { transform: rotate(180deg); }

.dsb-menu {
  position: absolute;
  z-index: 10;
  top: calc(100% + 10px);
  right: 0;
  min-width: 230px;
  margin: 0;
  padding: 6px;
  list-style: none;
  background: #23262e;
  border: 1px solid #33363f;
  border-radius: 14px;
  box-shadow: 0 26px 54px -18px rgba(0, 0, 0, .6);
  transform-origin: top right;
  opacity: 0;
  transform: translateY(-6px) scale(.97);
  transition: opacity .16s ease, transform .16s ease;
}
.dsb-menu[hidden] { display: none; }
.dsb-menu.is-open { opacity: 1; transform: translateY(0) scale(1); }

.dsb-item {
  width: 100%;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px;
  font: inherit;
  font-size: 13.5px;
  color: #e7e2d6;
  background: none;
  border: none;
  border-radius: 9px;
  text-align: left;
  cursor: pointer;
  transition: background .14s ease;
}
.dsb-item:hover,
.dsb-item.is-active { background: #2f333c; }
.dsb-item:focus-visible { outline: 2px solid #fbbf24; outline-offset: -2px; }
.dsb-item__icon { flex: none; width: 20px; text-align: center; font-size: 13px; color: #b7a98a; }
.dsb-item__label { flex: 1; }
.dsb-item__check { flex: none; opacity: 0; color: #f59e0b; font-size: 12px; }
.dsb-item[aria-checked="true"] .dsb-item__check { opacity: 1; }
.dsb-item[aria-checked="true"] .dsb-item__label { font-weight: 700; color: #fff; }

.dsb-hint {
  margin: 0;
  font-size: 12.5px;
  color: #9a9488;
  text-align: center;
  max-width: 320px;
  line-height: 1.7;
  min-height: 1.4em;
}

@media (prefers-reduced-motion: reduce) {
  .dsb-menu, .dsb-caret { transition: none; }
  .dsb-main.is-pulsing { animation: none; }
}

【JavaScript】
// スプリットボタン: ▼側で書き出し形式を選ぶと既定形式が変わり、主ボタンはその形式のまま実行する
(() => {
  const main = document.getElementById('dsbMain');
  const mainLabel = document.getElementById('dsbMainLabel');
  const toggle = document.getElementById('dsbToggle');
  const menu = document.getElementById('dsbMenu');
  const status = document.getElementById('dsbStatus');
  const group = toggle ? toggle.closest('.dsb-group') : null;
  if (!main || !mainLabel || !toggle || !menu || !status || !group) return;

  const items = Array.from(menu.querySelectorAll('.dsb-item'));
  if (!items.length) return;

  const isOpen = () => toggle.getAttribute('aria-expanded') === 'true';

  const setActive = (index) => {
    items.forEach((it, i) => it.classList.toggle('is-active', i === index));
  };

  const open = () => {
    menu.hidden = false;
    void menu.offsetHeight; // 強制リフローでトランジションを確実に発火させる
    menu.classList.add('is-open');
    toggle.setAttribute('aria-expanded', 'true');
    const checkedIndex = items.findIndex((it) => it.getAttribute('aria-checked') === 'true');
    const idx = checkedIndex >= 0 ? checkedIndex : 0;
    setActive(idx);
    items[idx].focus();
  };

  const close = (focusToggle) => {
    menu.hidden = true;
    menu.classList.remove('is-open');
    toggle.setAttribute('aria-expanded', 'false');
    setActive(-1);
    if (focusToggle) toggle.focus();
  };

  toggle.addEventListener('click', () => {
    if (isOpen()) close(false); else open();
  });

  toggle.addEventListener('keydown', (e) => {
    if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      open();
    }
  });

  const focusByIndex = (index) => {
    const len = items.length;
    const i = ((index % len) + len) % len;
    setActive(i);
    items[i].focus();
  };

  const selectFormat = (index) => {
    const item = items[index];
    if (!item) return;
    items.forEach((it) => it.setAttribute('aria-checked', 'false'));
    item.setAttribute('aria-checked', 'true');
    const fmt = item.dataset.format || '';
    const label = item.querySelector('.dsb-item__label');
    mainLabel.textContent = `${fmt}でエクスポート`;
    status.textContent = `既定の形式を${label ? label.textContent : fmt}に変更しました`;
    close(true);
  };

  menu.addEventListener('keydown', (e) => {
    const current = items.indexOf(document.activeElement);
    switch (e.key) {
      case 'ArrowDown': e.preventDefault(); focusByIndex(current + 1); break;
      case 'ArrowUp': e.preventDefault(); focusByIndex(current - 1); break;
      case 'Home': e.preventDefault(); focusByIndex(0); break;
      case 'End': e.preventDefault(); focusByIndex(items.length - 1); break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (current >= 0) selectFormat(current);
        break;
      case 'Escape': e.preventDefault(); close(true); break;
      case 'Tab': close(false); break;
      default: break;
    }
  });

  items.forEach((item, index) => {
    item.addEventListener('click', () => selectFormat(index));
    item.addEventListener('mouseenter', () => setActive(index));
  });

  main.addEventListener('click', () => {
    if (isOpen()) close(false);
    main.classList.remove('is-pulsing');
    void main.offsetWidth; // 強制リフローで連続クリックでもアニメを再発火させる
    main.classList.add('is-pulsing');
    status.textContent = `${mainLabel.textContent}を実行しました`;
  });
  main.addEventListener('animationend', () => main.classList.remove('is-pulsing'));

  document.addEventListener('click', (e) => {
    if (isOpen() && !group.contains(e.target)) close(false);
  });
})();

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

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