スプリットボタン
主アクションボタンと▼ボタンを分離したスプリットボタンです。▼側でエクスポート形式を選ぶと既定の形式が切り替わり、主ボタンはワンクリックでその形式のまま実行します。
ライブデモ
コード
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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。