デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
「メールで通知を受け取る」の親のトグルの下に、「お知らせ」「キャンペーン」などの子のトグルが並びます。親を切ると、入っている子が下から順に 1 つずつ切れていき、切れ終わると子の段が畳まれて、灰色の一文「メールでの通知はすべて停止中です」になります。下の段は、畳んだ分だけ詰めて上がります。親を入れ直すと段が開き、子は切る前の状態へ上から順に戻ります。もともと切っていた子は、切れたままです。
子は 0.15 秒遅れて切れ始め、0.12 秒おきに 1 つずつ切れます。一度にまとめて切らずに順に切るのは、親の操作が子の設定を上書きしたことを、目で追えるようにするためです。切れ終わってから 0.1 秒おいて、段を 0.45 秒の power3.inOut で畳みます。一文は子の文字が消えきってから出します。子と一文が同じ場所で重なると、途中で読めなくなるためです。
トグルは、本物の checkbox にスイッチの役割を付けたもので、Space で切り替わり、フォームでそのまま送れます。つまみと地の色の動きは CSS の transition に任せ、JS は checked を切り替える時刻だけを決めます。JS から checked を変えても change は起きないので、子の切り替えが親の処理を呼び返すことはありません。親を切る前の子の状態は覚えておき、入れ直したときに戻します。戻している途中で切り直したときは、前に覚えた状態をそのまま使います。
畳んでいる間は、子の段を inert にして、Tab で見えない子に入らないようにします。一文は status の領域に置き、中の文を出したときに読み上げに届くようにします。切れているトグルの地は、白い面の上では罫線の色だと白いつまみと見分けにくいので、1 段濃い灰色にしています。このデモでは親と子のトグルをクリックで切り替えられ、キーボードの操作はコードのほうの本物の checkbox が受けます。
1 つのトグルが切り替わる動きや、質問を押して答えを開くアコーディオンと違い、親と子の間の決まり(親を切ると子もまとめて切れ、入れ直すと元に戻る)を、順番と畳み方で見せる作りです。通知やメールマガジンの配信の設定、アプリの権限の設定、クッキーの同意の細目に向いています。間隔を 0 にすると子が一斉に切れます。動きを減らす設定では、ほぼ一瞬で切り替えます。
コード
<!-- トグルは role="switch" の本物の checkbox。Space で切り替わり、フォームでそのまま送れる -->
<form class="notify">
<div class="notify__group">
<label class="setting setting--parent">
<span class="setting__text"><span class="setting__title">メールで通知を受け取る</span><span class="setting__note">sample@higashiyuki.com に届きます</span></span>
<input class="setting__input" type="checkbox" role="switch" name="mail" checked>
<span class="switch" aria-hidden="true"></span>
</label>
<!-- 親を切ると、子が下から順に切れてから、この段が一文に畳まれる -->
<div class="notify__children">
<div class="notify__list">
<label class="setting">
<span class="setting__text"><span class="setting__title">お知らせ</span><span class="setting__note">サービスの更新やメンテナンスの予定</span></span>
<input class="setting__input" type="checkbox" role="switch" name="mail-news" checked>
<span class="switch" aria-hidden="true"></span>
</label>
<label class="setting">
<span class="setting__text"><span class="setting__title">キャンペーン</span><span class="setting__note">セールやクーポンのご案内</span></span>
<input class="setting__input" type="checkbox" role="switch" name="mail-campaign">
<span class="switch" aria-hidden="true"></span>
</label>
<label class="setting">
<span class="setting__text"><span class="setting__title">ご注文の状況</span><span class="setting__note">発送やお届けの予定</span></span>
<input class="setting__input" type="checkbox" role="switch" name="mail-order" checked>
<span class="switch" aria-hidden="true"></span>
</label>
<label class="setting">
<span class="setting__text"><span class="setting__title">新着のおすすめ</span><span class="setting__note">お気に入りのブランドの新作</span></span>
<input class="setting__input" type="checkbox" role="switch" name="mail-new" checked>
<span class="switch" aria-hidden="true"></span>
</label>
</div>
<!-- 畳んだときの一文。中の文を出したときに読み上げに届くよう、status の領域に置く -->
<p class="notify__off" role="status"><span hidden>メールでの通知はすべて停止中です</span></p>
</div>
</div>
<div class="notify__group">
<label class="setting setting--parent">
<span class="setting__text"><span class="setting__title">プッシュ通知を受け取る</span><span class="setting__note">アプリを開いていないときも届きます</span></span>
<input class="setting__input" type="checkbox" role="switch" name="push" checked>
<span class="switch" aria-hidden="true"></span>
</label>
</div>
</form>
.notify {
--ink: #262626; /* 文字・入っているトグルの地 */
--mute: #898989; /* 停止中の一文 */
--mute-small: #6b6b6b; /* 小さい補足 */
--line: rgba(38, 38, 38, 0.12); /* 区切り線・子の段の縦の線 */
--off: #d9d9d9; /* 切れているトグルの地(白い面の上なので、罫線の色より 1 段濃くする) */
--surface: #ffffff;
--ease: cubic-bezier(0.65, 0, 0.35, 1); /* power2.inOut に近い曲線 */
max-width: 544px;
padding: 16px 40px;
border-radius: 4px;
background: var(--surface);
color: var(--ink);
}
.notify__group + .notify__group { margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--line); }
.setting { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 56px; cursor: pointer; }
.setting--parent { min-height: 72px; }
.setting__text { display: grid; gap: 2px; min-width: 0; }
.setting__title { font-size: 15px; font-weight: 600; letter-spacing: 0.04em; line-height: 1.4; }
.setting__note { font-size: 12px; letter-spacing: 0.04em; line-height: 1.5; color: var(--mute-small); }
.setting--parent .setting__title { font-size: 16px; font-weight: 700; }
.setting--parent .setting__note { font-size: 13px; }
/* 本物の checkbox は見えなくするだけ(display: none にすると、キーボードで切り替えられなくなる) */
.setting__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
/* トグル: 入ると墨色の地に。つまみはいつも白く、地より明るいままにする */
.switch {
--w: 40px;
--h: 24px;
position: relative;
flex: none;
width: var(--w);
height: var(--h);
border-radius: 999px;
background: var(--off);
transition: background-color 0.3s var(--ease);
}
.switch::before {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: calc(var(--h) - 6px);
height: calc(var(--h) - 6px);
border-radius: 50%;
background: var(--surface);
transition: translate 0.3s var(--ease);
}
.setting--parent .switch { --w: 48px; --h: 28px; }
.setting__input:checked + .switch { background: var(--ink); }
.setting__input:checked + .switch::before { translate: calc(var(--w) - var(--h)) 0; }
.setting__input:focus-visible + .switch { outline: 2px solid var(--ink); outline-offset: 3px; }
/* 子の段: 左の縦の線で、親の下にあることを示す。畳むときは JS がこの箱の高さを動かす */
.notify__children { position: relative; overflow: hidden; }
.notify__list { margin-left: 8px; padding: 8px 0 8px 16px; border-left: 1px solid var(--line); }
.notify__off { position: absolute; top: 0; left: 24px; right: 0; display: flex; align-items: center; height: 48px; margin: 0; font-size: 14px; letter-spacing: 0.04em; color: var(--mute); opacity: 0; }
@media (max-width: 480px) {
.notify { padding: 8px 20px; }
.setting__note { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.switch,
.switch::before { transition: none; }
}
import { gsap } from "gsap";
// 動きを減らす設定では、どの動きもほぼ一瞬にする(長さ 0 にしないのは、並べたトゥイーンの順序を崩さないため)
const speed = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0.02 : 1;
const STEP = 0.12; // 子が 1 つずつ切れる・戻る間隔(秒)
document.querySelectorAll(".notify__group").forEach((group) => {
const box = group.querySelector(".notify__children");
if (!box) return; // 子を持たない段(プッシュ通知など)は、ふつうのトグルのまま
const parent = group.querySelector(".setting--parent .setting__input");
const list = box.querySelector(".notify__list");
const off = box.querySelector(".notify__off");
const children = [...list.querySelectorAll(".setting__input")];
let memory = children.map((input) => input.checked); // 親を入れ直したときに戻す状態
let tl;
// トグルの動きは CSS の transition に任せ、JS は切り替える時刻だけを決める(checked を変えても change は起きない)
const flip = (input, value, at) => tl.call(() => (input.checked = value), null, at * speed);
const close = (remember) => {
if (remember) memory = children.map((input) => input.checked);
const order = children.filter((input) => input.checked).reverse(); // 入っている子を、下から順に切る
tl = gsap.timeline();
order.forEach((input, k) => flip(input, false, 0.15 + STEP * k));
const at = (0.15 + STEP * Math.max(order.length - 1, 0) + 0.4) * speed; // 切れ終わってから畳む
list.inert = true; // 畳んでいる間は、子にタブで入らないように
tl.fromTo(box, { height: box.offsetHeight }, { height: off.offsetHeight, duration: 0.45 * speed, ease: "power3.inOut" }, at)
.to(list, { opacity: 0, duration: 0.2 * speed, ease: "none" }, at)
.call(() => (off.firstElementChild.hidden = false), null, at + 0.24 * speed) // 文を入れたときに読み上げに届く
.to(off, { opacity: 1, duration: 0.3 * speed, ease: "none" }, at + 0.24 * speed);
};
const open = () => {
list.inert = false;
tl = gsap.timeline()
.to(off, { opacity: 0, duration: 0.15 * speed, ease: "none" }, 0)
.call(() => (off.firstElementChild.hidden = true), null, 0.15 * speed)
.to(box, { height: list.offsetHeight, duration: 0.45 * speed, ease: "power3.inOut" }, 0)
.to(list, { opacity: 1, duration: 0.3 * speed, ease: "none" }, 0.15 * speed)
.set(box, { height: "auto" }); // 開ききったら、中身に合わせた高さへ戻す
// 切る前の状態へ、上から順に戻す(もともと切っていた子は切れたまま)
let k = 0;
memory.forEach((was, i) => was && flip(children[i], true, 0.5 + STEP * k++));
};
parent.addEventListener("change", () => {
const restoring = !parent.checked && tl?.isActive(); // 戻している途中で切り直したら、前に覚えた状態を保つ
tl?.kill(); // 途中で押し直したら、今の状態から逆へ向かう
if (parent.checked) open();
else close(!restoring);
});
// 最初から親が切れているときは、動かさずに畳んだ形にする
if (!parent.checked) {
children.forEach((input) => (input.checked = false));
list.inert = true;
gsap.set(list, { opacity: 0 });
gsap.set(off, { opacity: 1 });
gsap.set(box, { height: off.offsetHeight });
off.firstElementChild.hidden = false;
}
});