本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
フォーム

通知の設定で親のトグルを切ると、子のトグルがまとめて切れて畳まれる

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
フォーム
動作
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 にすると子が一斉に切れます。動きを減らす設定では、ほぼ一瞬で切り替えます。

コード

HTML
<!-- トグルは 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>

CSS
.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; }
}

JavaScript
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;
  }
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。