ぷにっと動くトグルボタン
JavaScriptタッチ操作可能ボタン
切替時にボタンがぷにっと変形しながら変わるトグルボタンです。
実装のポイント
トグルボタンのUIだけであればCSSとHTMLのみでも実装可能ですが、ボタンの動きが少し複雑なのでJavaScriptのWebアニメーションAPIを使っています。
HTMLの実態としてはチェックボックスです。チェック時・非チェック時をトグルボタンのオン・オフに対応しています。ただチェックボックス自体は見せたくないのでsrOnly
というタグで不可視にしています。srOnly
の使い方は『不可視だけど存在するsrOnly』にて紹介しています。
チェック時のスタイルは.toggleCheck:checked ~
という後続兄弟結合子を使うとチェックされた要素の後ろにある要素を選択できます。そのためHTML順は重要です。アイコンをチェックボックスの後ろにおいているのもそのためです。
キーフレームの動きとしては、移動のはじまりを少し遅くしています。変形が始まってから動くほうが有機的に感じられます。
チェックボックスが最初からチェックが入っている場合もあります。その場合は動きがズレてしまうので、初回にトグルボタンの位置が同期するようアニメーション時間なしで実行しています。
なお、ネイティブのHTMLでトグルボタンのUIを提供する提案も出されていますが、2025年5月現在、Safariのみの対応となっているのでまだ現実的ではありません。
コード例
コード例では1つのトグルボタンのHTMLを掲載しています。
HTML
<label class="toggle">
<input type="checkbox" class="toggleCheck srOnly" />
<span class="toggleText">Toggle Button</span>
<span class="iconWrapper">
<span class="toggleIcon"></span>
</span>
</label>
CSS
.toggle {
display: flex;
align-items: center;
gap: 10px;
--off-color: #ddd; /* オフの時の背景色 */
--on-color: #007bff; /* オンの時の背景色 */
--button-size: 26px; /* ボタンのサイズ */
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
}
.iconWrapper {
position: relative;
width: calc(var(--button-size) * 2 + 4px);
height: calc(var(--button-size) + 4px);
border-radius: 15px;
background-color: var(--off-color);
transition: background-color 0.3s var(--ease-out-quart);
cursor: pointer;
}
.toggleIcon {
position: absolute;
top: 2px;
left: 2px;
width: var(--button-size);
height: var(--button-size);
border-radius: 50%;
background-color: #fff;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
}
/** チェック時のスタイル */
.toggleCheck:checked ~ .iconWrapper {
background-color: var(--on-color);
.toggleIcon {
translate: var(--button-size) 0;
}
}
/** フォーカス時のスタイル */
.toggleCheck:focus-visible ~ .iconWrapper {
outline: 2px solid var(--on-color);
outline-offset: 2px;
}
JavaScript
const toggleCheckElements = document.querySelectorAll(".toggleCheck");
// イージング関数
const easeOutQuart = "cubic-bezier(0.25, 1, 0.5, 1)";
// チェック時のアニメーションキーフレーム
const checkedAnimationKeyframes = [
{
scale: 1,
transformOrigin: "left center",
easing: easeOutQuart,
offset: 0,
},
{
translate: "0 0",
easing: easeOutQuart,
offset: 0.1,
},
{
scale: "1.3 0.7",
easing: easeOutQuart,
offset: 0.4,
},
{ translate: "var(--button-size) 0", offset: 0.8 },
{ translate: "var(--button-size) 0", scale: 1, offset: 1 },
];
// チェック解除時のアニメーションキーフレーム
const uncheckedAnimationKeyframes = [
{
scale: 1,
transformOrigin: "right center",
easing: easeOutQuart,
offset: 0,
},
{
translate: "var(--button-size) 0",
easing: easeOutQuart,
offset: 0.15,
},
{ scale: "1.3 0.7", easing: easeOutQuart, offset: 0.4 },
{ scale: 1, offset: 0.8 },
{ translate: "0 0" },
];
/**
* トグルボタンのアニメーションを実行
* @param {HTMLElement} element - アニメーションを実行する要素
* @param {boolean} checked - チェック状態
* @param {boolean} immediate - 即時実行するかどうか
*/
const toggleAnimation = (element, checked, immediate = false) => {
if (checked) {
element.animate(checkedAnimationKeyframes, {
duration: immediate ? 0 : 450,
iterations: 1,
fill: "forwards",
});
} else {
element.animate(uncheckedAnimationKeyframes, {
duration: immediate ? 0 : 270,
iterations: 1,
fill: "forwards",
});
}
};
// チェック状態が変更された時にアニメーションを実行
toggleCheckElements.forEach((toggleCheckElement) => {
toggleCheckElement.addEventListener("change", (event) => {
const toggleIconElement =
toggleCheckElement.parentElement.querySelector(".toggleIcon");
toggleAnimation(toggleIconElement, event.target.checked);
});
});
// ページ読み込み時にチェック状態と同期するために即時実行
toggleCheckElements.forEach((toggleCheckElement) => {
const toggleIconElement =
toggleCheckElement.parentElement.querySelector(".toggleIcon");
toggleAnimation(toggleIconElement, toggleCheckElement.checked, true);
});