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

セクション

よくある質問をカテゴリで絞ると、残った質問が詰めて並ぶ

デモ

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

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

「配送」や「返品」のボタンを押すと、当てはまらない質問がふっと消え、残った質問が上へ滑って隙間を詰めます。質問の行を押せば答えが下へ開き、開いた行だけが白い面に変わります。答えを開いたままカテゴリを切り替えても、その質問は消えきってから閉じるので、次に並んだときには閉じた形に戻っています。

並び替えには GSAP の Flip プラグインを使いました。行を外す前に全部の位置を記録しておき、外したあとの位置との差を埋めるように動かすので、途中で別のボタンを押しても、行はいまいる位置から新しい並びへ向きを変えます。厄介なのは、動いている間の行が絶対配置になることです。そのままだと一覧の高さが一瞬0に潰れ、下に続く中身が跳ねてしまいます。一覧の高さも前の値から新しい値へ同じ時間で補間し、終わったら指定を外すようにしました。

残る行は0.6秒の power3.inOut で、全部同時に滑らせます。1行ずつ少し遅らせると、遠くへ動く行が前の行に追いついて重なってしまうので、ずらしは入れていません。消える行は0.25秒で透明になるだけで、縮みはしません。加わる行は、消える行が退いてから16px 下から上がってきます。滑る行は背景色で塗って手前に置いたので、加わる行の上を通っても文字が重なって見えません。

答えの開閉は、height を0から auto へ補間しています。開いた行の白い面と + の丸の塗りは CSS 変数の --fill、縦棒の回転は --turn で決めているので、色を変えたければ CSS を書き換えるだけです。押しているボタンは aria-pressed で伝え、絞り込んだ件数は画面には表示せずに、スクリーンリーダーにだけ届く文で知らせています。

コード

HTML
<section class="faq" aria-labelledby="faq-title">
  <p class="faq__label">Q&A</p>
  <h2 class="faq__title" id="faq-title">よくあるご質問</h2>
  <div class="faq__chips" role="group" aria-label="カテゴリで絞り込む">
    <button class="faq__chip" type="button" data-filter="all" aria-pressed="true">すべて</button>
    <button class="faq__chip" type="button" data-filter="order" aria-pressed="false">ご注文</button>
    <button class="faq__chip" type="button" data-filter="payment" aria-pressed="false">お支払い</button>
    <button class="faq__chip" type="button" data-filter="delivery" aria-pressed="false">配送</button>
    <button class="faq__chip" type="button" data-filter="return" aria-pressed="false">返品</button>
  </div>
  <!-- 絞り込んだ件数は、スクリーンリーダーにだけ知らせる -->
  <p class="faq__status" role="status"></p>
  <div class="faq__list">
    <div class="faq__item" data-cat="delivery">
      <h3 class="faq__head"><button class="faq__q" type="button" id="faq-q1" aria-expanded="false" aria-controls="faq-a1"><span class="faq__mark" aria-hidden="true">Q</span><span class="faq__text">送料はいくらですか?</span><span class="faq__cat">配送</span><span class="faq__icon" aria-hidden="true"></span></button></h3>
      <div class="faq__a" id="faq-a1" role="region" aria-labelledby="faq-q1"><div class="faq__inner"><span class="faq__mark" aria-hidden="true">A</span><p>全国一律550円です。<br>5,000円以上のご注文で、送料無料になります。</p></div></div>
    </div>
    <div class="faq__item" data-cat="payment">
      <h3 class="faq__head"><button class="faq__q" type="button" id="faq-q2" aria-expanded="false" aria-controls="faq-a2"><span class="faq__mark" aria-hidden="true">Q</span><span class="faq__text">利用できるお支払い方法を教えてください。</span><span class="faq__cat">お支払い</span><span class="faq__icon" aria-hidden="true"></span></button></h3>
      <div class="faq__a" id="faq-a2" role="region" aria-labelledby="faq-q2"><div class="faq__inner"><span class="faq__mark" aria-hidden="true">A</span><p>クレジットカード、コンビニ払い、銀行振込、代金引換がご利用いただけます。<br>代金引換の手数料は330円です。</p></div></div>
    </div>
    <div class="faq__item" data-cat="order">
      <h3 class="faq__head"><button class="faq__q" type="button" id="faq-q3" aria-expanded="false" aria-controls="faq-a3"><span class="faq__mark" aria-hidden="true">Q</span><span class="faq__text">注文後に内容を変更できますか?</span><span class="faq__cat">ご注文</span><span class="faq__icon" aria-hidden="true"></span></button></h3>
      <div class="faq__a" id="faq-a3" role="region" aria-labelledby="faq-q3"><div class="faq__inner"><span class="faq__mark" aria-hidden="true">A</span><p>発送準備に入る前であれば、マイページの注文履歴から変更できます。<br>準備中の場合は、お問い合わせフォームからご連絡ください。</p></div></div>
    </div>
    <div class="faq__item" data-cat="return">
      <h3 class="faq__head"><button class="faq__q" type="button" id="faq-q4" aria-expanded="false" aria-controls="faq-a4"><span class="faq__mark" aria-hidden="true">Q</span><span class="faq__text">返品・交換はできますか?</span><span class="faq__cat">返品</span><span class="faq__icon" aria-hidden="true"></span></button></h3>
      <div class="faq__a" id="faq-a4" role="region" aria-labelledby="faq-q4"><div class="faq__inner"><span class="faq__mark" aria-hidden="true">A</span><p>商品到着後8日以内で、未使用のものに限り承ります。<br>お客様のご都合による返品の送料は、お客様のご負担となります。</p></div></div>
    </div>
    <div class="faq__item" data-cat="delivery">
      <h3 class="faq__head"><button class="faq__q" type="button" id="faq-q5" aria-expanded="false" aria-controls="faq-a5"><span class="faq__mark" aria-hidden="true">Q</span><span class="faq__text">注文してから何日で届きますか?</span><span class="faq__cat">配送</span><span class="faq__icon" aria-hidden="true"></span></button></h3>
      <div class="faq__a" id="faq-a5" role="region" aria-labelledby="faq-q5"><div class="faq__inner"><span class="faq__mark" aria-hidden="true">A</span><p>ご注文から2〜4日でお届けします。<br>お届け日時の指定は、ご注文手続きの画面で承ります。</p></div></div>
    </div>
    <div class="faq__item" data-cat="payment">
      <h3 class="faq__head"><button class="faq__q" type="button" id="faq-q6" aria-expanded="false" aria-controls="faq-a6"><span class="faq__mark" aria-hidden="true">Q</span><span class="faq__text">領収書は発行できますか?</span><span class="faq__cat">お支払い</span><span class="faq__icon" aria-hidden="true"></span></button></h3>
      <div class="faq__a" id="faq-a6" role="region" aria-labelledby="faq-q6"><div class="faq__inner"><span class="faq__mark" aria-hidden="true">A</span><p>マイページの注文履歴から、PDFの領収書をダウンロードできます。<br>宛名は発行するときに入力してください。</p></div></div>
    </div>
    <div class="faq__item" data-cat="order">
      <h3 class="faq__head"><button class="faq__q" type="button" id="faq-q7" aria-expanded="false" aria-controls="faq-a7"><span class="faq__mark" aria-hidden="true">Q</span><span class="faq__text">注文確認メールが届きません。</span><span class="faq__cat">ご注文</span><span class="faq__icon" aria-hidden="true"></span></button></h3>
      <div class="faq__a" id="faq-a7" role="region" aria-labelledby="faq-q7"><div class="faq__inner"><span class="faq__mark" aria-hidden="true">A</span><p>迷惑メールフォルダに振り分けられていないか、ご確認ください。<br>1時間以上たっても届かない場合は、お問い合わせください。</p></div></div>
    </div>
    <div class="faq__item" data-cat="return">
      <h3 class="faq__head"><button class="faq__q" type="button" id="faq-q8" aria-expanded="false" aria-controls="faq-a8"><span class="faq__mark" aria-hidden="true">Q</span><span class="faq__text">届いた商品が破損していました。</span><span class="faq__cat">返品</span><span class="faq__icon" aria-hidden="true"></span></button></h3>
      <div class="faq__a" id="faq-a8" role="region" aria-labelledby="faq-q8"><div class="faq__inner"><span class="faq__mark" aria-hidden="true">A</span><p>お手数ですが、商品の写真を添えてお問い合わせください。<br>送料は当店負担で、交換品をすぐにお送りします。</p></div></div>
    </div>
  </div>
</section>

CSS
.faq {
  --ink: #262626; /* 文字と線の色 */
  --paper: #f2f1ed; /* 背景色 */
  --surface: #ffffff; /* 開いた行の面 */
  --mute: #898989; /* 答えの「A」 */
  --mute-small: #6b6b6b; /* 小さなラベル */
  --line: rgba(38, 38, 38, 0.12); /* 区切り線 */
  max-width: 896px;
  margin: 0 auto;
  padding: 56px 16px;
  background: var(--paper);
  color: var(--ink);
}

.faq__label { margin: 0; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; color: var(--mute-small); }
.faq__title { margin: 16px 0 0; font-size: 44px; font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }

.faq__chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 40px 0 24px; }

.faq__chip {
  height: 40px;
  padding: 0 20px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
  white-space: nowrap;
  cursor: pointer;
  transition: background-color 0.25s, color 0.25s, box-shadow 0.25s;
}

/* 選んでいるボタンは、色を足さずに明暗を反転する */
.faq__chip[aria-pressed="true"] { background: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); color: var(--surface); }
.faq__chip:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

.faq__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* Flip は動いている間、行を絶対配置にする。その基準になる */
.faq__list { position: relative; border-top: 1px solid var(--line); }

/* --fill(面と丸の塗り)と --turn(縦棒の回転)を GSAP で 0 → 1 に動かす。
   --rise は加わる行が上がってくる量。行は Flip が transform で置くので、中身のほうを translate で動かす */
.faq__item {
  --fill: 0;
  --turn: 0;
  --rise: 0;
  /* 閉じている行も背景色で塗る。滑っていく行が、下に現れた行の上を通るときに文字が重ならない */
  background: color-mix(in srgb, var(--surface) calc(var(--fill) * 100%), var(--paper));
  box-shadow: inset 0 -1px 0 var(--line);
}

.faq__item > * { translate: 0 calc(var(--rise) * 1px); }
.faq__head { margin: 0; }

.faq__q {
  display: flex;
  align-items: center;
  gap: 16px;
  width: 100%;
  min-height: 52px;
  padding: 0 16px;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.faq__q:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }
.faq__mark { flex: none; width: 16px; font-size: 18px; font-weight: 700; line-height: 1; }
.faq__text { flex: 1; padding: 12px 0; font-size: 17px; font-weight: 700; line-height: 1.5; letter-spacing: 0.02em; word-break: auto-phrase; }
.faq__cat { flex: none; padding: 6px 10px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--line); color: var(--mute-small); font-size: 12px; font-weight: 600; line-height: 1; letter-spacing: 0.06em; }

/* + は横棒と縦棒。縦棒が 90° 回って − になり、丸が塗られて棒が白くなる */
.faq__icon { position: relative; flex: none; width: 28px; height: 28px; border-radius: 50%; box-shadow: inset 0 0 0 1px var(--ink); background: color-mix(in srgb, var(--ink) calc(var(--fill) * 100%), transparent); }
.faq__icon::before,
.faq__icon::after { content: ""; position: absolute; left: 50%; top: 50%; width: 12px; height: 2px; margin: -1px 0 0 -6px; background: color-mix(in srgb, var(--surface) calc(var(--fill) * 100%), var(--ink)); }
.faq__icon::after { rotate: calc(90deg + var(--turn) * 90deg); }

/* 高さを 0 から中身の高さまで伸ばす。はみ出しを隠さないと、閉じていても答えが見える */
.faq__a { height: 0; overflow: hidden; }
.faq__inner { display: flex; gap: 16px; padding: 2px 64px 20px 16px; }
.faq__inner .faq__mark { color: var(--mute); }
.faq__inner p { margin: 0; font-size: 15px; line-height: 26px; letter-spacing: 0.04em; word-break: auto-phrase; }

@media (max-width: 600px) {
  .faq__title { font-size: 32px; }
  .faq__inner { padding-right: 16px; }
}

@media (prefers-reduced-motion: reduce) {
  .faq__chip { transition: none; }
}

JavaScript
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";

gsap.registerPlugin(Flip);

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const MOVE = 0.6; // 残った行が新しい位置へ滑る時間(秒)。行ごとにずらすと、遠くへ動く行が前の行に追いついて重なる
const LEAVE = 0.25; // 当てはまらない行が消える時間
const ENTER = 0.45; // 加わる行が現れる時間
const ENTER_DELAY = 0.3; // 消える行が退いてから現れる
const OPEN = 0.5; // 答えの開閉

document.querySelectorAll(".faq").forEach((faq) => {
  const chips = [...faq.querySelectorAll(".faq__chip")];
  const list = faq.querySelector(".faq__list");
  const items = [...faq.querySelectorAll(".faq__item")];
  const status = faq.querySelector(".faq__status");

  // 答えの開閉。高さは "auto" で渡してよい(GSAP がその場で中身の高さを測る)
  const toggles = items.map((item) => {
    const tl = gsap
      .timeline({ paused: true })
      .fromTo(item.querySelector(".faq__a"), { height: 0 }, { height: "auto", duration: OPEN, ease: "power2.inOut" }, 0)
      .fromTo(item.querySelector(".faq__inner"), { autoAlpha: 0, y: -8 }, { autoAlpha: 1, y: 0, duration: 0.35, ease: "power2.out" }, 0.12)
      .fromTo(item, { "--fill": 0 }, { "--fill": 1, duration: 0.3, ease: "none" }, 0)
      .fromTo(item, { "--turn": 0 }, { "--turn": 1, duration: 0.4, ease: "power2.inOut" }, 0);
    if (reduceMotion) tl.timeScale(20);
    return tl;
  });

  const setOpen = (i, open) => {
    items[i].querySelector(".faq__q").setAttribute("aria-expanded", String(open));
    if (open) toggles[i].play();
    else toggles[i].reverse();
  };

  items.forEach((item, i) => {
    const button = item.querySelector(".faq__q");
    button.addEventListener("click", () => setOpen(i, button.getAttribute("aria-expanded") !== "true"));
  });

  chips.forEach((chip) => {
    chip.addEventListener("click", () => {
      if (chip.getAttribute("aria-pressed") === "true") return;
      const filter = chip.dataset.filter;
      const from = list.offsetHeight; // 動いている途中なら、途中の高さ
      // 1. 今の位置を記録する  2. 当てはまらない行を外す  3. 記録した位置から新しい位置へ補間する
      const state = Flip.getState(items);
      items.forEach((item) => {
        item.style.display = filter === "all" || item.dataset.cat === filter ? "" : "none";
      });
      chips.forEach((c) => c.setAttribute("aria-pressed", String(c === chip)));
      status.textContent = `${items.filter((item) => item.style.display !== "none").length}件の質問を表示しています`;
      // 外した行は、消えきってから閉じる(次に表示されたときは閉じた形で並ぶ)
      const close = (els) => els.forEach((el) => setOpen(items.indexOf(el), false));

      if (reduceMotion) {
        close(items.filter((item) => item.style.display === "none"));
        return;
      }
      list.style.height = "";
      const to = list.offsetHeight;
      Flip.from(state, {
        duration: MOVE,
        ease: "power3.inOut",
        absolute: true, // 動いている間は流れから外す。外さないと、詰め直しに引きずられて行き先がずれる
        zIndex: 2, // 滑る行を、現れる行の手前に通す
        onEnter: (els) => gsap.fromTo(els, { autoAlpha: 0, "--rise": 16 }, { autoAlpha: 1, "--rise": 0, duration: ENTER, delay: ENTER_DELAY, stagger: 0.04, ease: "power3.out" }),
        onLeave: (els) => gsap.to(els, { autoAlpha: 0, duration: LEAVE, ease: "power2.in", onComplete: () => close(els) }),
      })
        // 行を流れから外すと一覧の高さが 0 に潰れ、下の中身が跳ねる。前の高さから新しい高さへ補間し、最後に外す
        .fromTo(list, { height: from }, { height: to, duration: MOVE, ease: "power3.inOut" }, 0)
        .set(list, { clearProps: "height" });
    });
  });

  // 幅が変われば答えの高さも変わる。いったん閉じた形に戻してから、測り直させる
  window.addEventListener("resize", () => {
    toggles.forEach((toggle) => {
      const progress = toggle.progress();
      toggle.progress(0).invalidate().progress(progress);
    });
  });
});

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

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