本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
アイコン

+ のアイコンが回って - に変わり、アコーディオンの開閉を示す

デモ

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

仕様

用途
アイコン
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

+ は横と縦の 2 本の線で描き、2 本をまとめた <g> と縦の線を別々に回します。押すと、印全体が 180° 回るあいだに、縦の線が 90° 倒れて横の線にぴったり重なり、- になります。もう一度押すと、同じ向きにもう 180° 回りながら縦の線が起き上がり、+ に戻ります。答えは高さを伸ばして文を浮かせるだけの控えめな動きにして、アイコンの切り替えを主役にしています。

印の半回転は 0.6 秒の back.out(1.4) で、少し行き過ぎてから止まります。縦の線は 0.45 秒の power2.inOut で、印より先に倒しきります。重なった 2 本が一緒に行き過ぎて戻るので、- がカチッとはまったように見えます。答えの高さは 0.45 秒で伸ばし、文は 0.12 秒遅れて浮かせます。同時に出すと、まだ狭い枠の中で文が動いて読みにくくなります。

角度は、押した回数に 180°(縦の線は 90°)を掛けて決めます。+=180 のように今の角度に足す書き方だと、回っている途中に押し直したとき、半端な角度に足してしまい、印が斜めのまま止まります。SVG の中の要素なので、回す中心は svgOrigin で印の中心(24 のグリッドで 12, 12)に置きます。

質問は見出しの中のボタンにし、開閉を aria-expanded で伝え、答えの領域と結び付けます。閉じた答えは hidden にして、読み上げとタブ移動から外します。開ききったら高さを auto に戻すので、画面の幅が変わって行数が増えても文が切れません。動きを減らす設定では、ほぼ一瞬で切り替えます。

閉じるときに逆向きに回したいなら、回数ではなく開閉で角度を決め、開くとき 180°・閉じるとき 0° にします。このデモは 24px の印を 4 倍に拡大して見せています。よくある質問、仕様や注意書きの折りたたみ、スマホのフッターのメニューの開閉に向いています。

コード

HTML
<!-- 質問は見出しの中のボタン。開閉の状態は aria-expanded、答えとは aria-controls で結ぶ -->
<div class="faq">
  <div class="faq__item">
    <h3 class="faq__heading">
      <button class="faq__question" id="faq-q1" type="button" aria-expanded="false" aria-controls="faq-a1">
        <span>送料はいくらかかりますか?</span>
        <svg class="faq__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
          <!-- 横と縦の 2 本。印ごと回しながら、縦の線だけを 90° 倒して横に重ねる -->
          <g class="faq__mark">
            <line x1="5" y1="12" x2="19" y2="12" />
            <line class="faq__bar" x1="12" y1="5" x2="12" y2="19" />
          </g>
        </svg>
      </button>
    </h3>
    <!-- 閉じている答えは hidden にして、読み上げとタブ移動から外す -->
    <div class="faq__answer" id="faq-a1" role="region" aria-labelledby="faq-q1" hidden>
      <p>全国一律¥550です。¥5,000以上のご注文で、送料は無料になります。</p>
    </div>
  </div>
  <div class="faq__item">
    <h3 class="faq__heading">
      <button class="faq__question" id="faq-q2" type="button" aria-expanded="false" aria-controls="faq-a2">
        <span>注文のあとで、届け先を変えられますか?</span>
        <svg class="faq__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
          <g class="faq__mark">
            <line x1="5" y1="12" x2="19" y2="12" />
            <line class="faq__bar" x1="12" y1="5" x2="12" y2="19" />
          </g>
        </svg>
      </button>
    </h3>
    <div class="faq__answer" id="faq-a2" role="region" aria-labelledby="faq-q2" hidden>
      <p>発送の準備が始まる前なら、注文履歴の画面から変更できます。</p>
    </div>
  </div>
</div>

CSS
.faq {
  --ink: #262626;                  /* 文字と印の色 */
  --line: rgba(38, 38, 38, 0.12);  /* 項目の区切り線 */
  --size: 24px;                    /* 印の大きさ */
  max-width: 720px;
  border-top: 1px solid var(--line);
  color: var(--ink);
}

.faq__item {
  border-bottom: 1px solid var(--line);
}

.faq__heading {
  margin: 0;
  font-size: 18px;
  font-weight: 700;
  line-height: 1.5;
  word-break: auto-phrase;
}

.faq__question {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  width: 100%;
  padding: 20px 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.faq__question:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 4px;
}

/* 24 グリッドで線幅 2(24px で描けば 2px)。線端は丸 */
.faq__icon {
  flex: none;
  width: var(--size);
  height: var(--size);
  overflow: visible;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}

/* 高さは script.js が 0 から中身の高さまで伸ばす */
.faq__answer {
  height: 0;
  overflow: hidden;
}

.faq__answer p {
  margin: 0;
  padding-bottom: 24px;
  font-size: 15px;
  line-height: 1.8;
  letter-spacing: 0.04em;
  word-break: auto-phrase;
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

document.querySelectorAll(".faq__item").forEach((item) => {
  const button = item.querySelector(".faq__question");
  const mark = item.querySelector(".faq__mark");
  const bar = item.querySelector(".faq__bar");
  const answer = item.querySelector(".faq__answer");
  const text = answer.firstElementChild;

  // 押した回数。奇数なら開いている。角度はこの回数から決める
  // ("+=180" で足すと、回っている途中に押し直したとき、斜めのまま止まる)
  let turns = button.getAttribute("aria-expanded") === "true" ? 1 : 0;
  gsap.set(mark, { rotation: turns * 180, svgOrigin: "12 12" });
  gsap.set(bar, { rotation: turns * 90, svgOrigin: "12 12" });
  if (turns) {
    answer.hidden = false;
    gsap.set(answer, { height: "auto" });
  }

  let anim = null;
  button.addEventListener("click", () => {
    turns += 1;
    const open = turns % 2 === 1;
    button.setAttribute("aria-expanded", String(open));
    if (open) answer.hidden = false;
    anim?.kill();
    anim = gsap
      .timeline({
        onComplete() {
          // 開いたら高さを auto に戻す(画面の幅が変わって行数が増えても切れない)
          if (open) gsap.set(answer, { height: "auto" });
          else answer.hidden = true;
        },
      })
      // 印ごと半回転(少し行き過ぎてから止まる)。縦の線は先に倒しきって横に重なる
      .to(mark, { rotation: turns * 180, svgOrigin: "12 12", duration: 0.6, ease: "back.out(1.4)" }, 0)
      .to(bar, { rotation: turns * 90, svgOrigin: "12 12", duration: 0.45, ease: "power2.inOut" }, 0)
      // 答えは控えめに、高さを伸ばして文を浮かせるだけ
      .to(answer, { height: open ? "auto" : 0, duration: open ? 0.45 : 0.4, ease: "power2.inOut" }, 0)
      .fromTo(text, { opacity: open ? 0 : 1, y: open ? -8 : 0 }, { opacity: open ? 1 : 0, y: 0, duration: open ? 0.35 : 0.15 }, open ? 0.12 : 0);
    // 動きを減らす設定では、ほぼ一瞬で切り替える
    if (reduceMotion) anim.progress(1);
  });
});

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

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