デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- 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 で伝え、絞り込んだ件数は画面には表示せずに、スクリーンリーダーにだけ届く文で知らせています。
コード
<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>
.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; }
}
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);
});
});
});