デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
答えを、はみ出しを隠した枠に入れておき、枠の height を 0 から中身の高さまで伸ばして開きます。下の項目は、ふつうの行送りのまま押し下げられるので、位置の計算は要りません。中身の文は、枠が開き始めてから少し遅れて浮かび上がります。
高さは 0.5 秒の power2.inOut、中身は 0.12 秒後から 0.35 秒で出します。同時に出すと、まだ狭い枠の中で文が動いて読みにくくなります。+ の印は、縦の棒を 0.4 秒かけて 90° 回して − に変え、あわせて丸を文字の色で塗り、明暗を反転します。
高さの行き先は auto で渡せます。GSAP が中身の高さをその場で測って px に直すので、答えの長さが項目ごとに違っても同じ書き方で済みます。ただし測った値はそのまま覚え込まれるため、画面の幅が変わって行数が増えたときは、閉じた形に戻してから測り直させます。
開くのは 1 つだけにしてあります。押すたびに、開いている項目を逆再生し、押した項目を再生します。同じ質問をもう一度押せば閉じます。状態は見た目だけでなく aria-expanded にも書き、質問を見出しの中のボタンにして、答えの領域と結び付けます。
複数を開けるようにするなら、開いている項目を閉じる 1 行を外すだけです。答えが長いときは、伸びる時間を 0.6 秒ほどまでゆるめると、動く量に対して速すぎません。よくある質問・料金表の注記・設定画面の折りたたみに向いています。
コード
<div class="faq">
<!-- <details> ではなく、見出しの中のボタンと領域で組む。<details> は open を外した瞬間に
中身が display:none になるので、閉じていく動きを見せられない。見出しの階層もページに合わせられる -->
<div class="faq__item">
<h2 class="faq__head">
<button class="faq__q" type="button" id="faq-q1" aria-expanded="false" aria-controls="faq-a1">
無料体験では、何ができますか?
<span class="faq__icon" aria-hidden="true">
<span class="faq__bar"></span>
<span class="faq__bar faq__bar--v"></span>
</span>
</button>
</h2>
<div class="faq__a" id="faq-a1" role="region" aria-labelledby="faq-q1">
<p class="faq__inner">14日間、すべての機能を無料でお試しいただけます。<br>カードの登録は不要で、設定画面からいつでも解約できます。</p>
</div>
</div>
<div class="faq__item">
<h2 class="faq__head">
<button class="faq__q" type="button" id="faq-q2" aria-expanded="false" aria-controls="faq-a2">
あとからプランを変更できますか?
<span class="faq__icon" aria-hidden="true">
<span class="faq__bar"></span>
<span class="faq__bar faq__bar--v"></span>
</span>
</button>
</h2>
<div class="faq__a" id="faq-a2" role="region" aria-labelledby="faq-q2">
<p class="faq__inner">はい、いつでも変更できます。<br>差額は日割りで計算し、次回のご請求に反映します。</p>
</div>
</div>
<div class="faq__item">
<h2 class="faq__head">
<button class="faq__q" type="button" id="faq-q3" aria-expanded="false" aria-controls="faq-a3">
データは安全に保管されますか?
<span class="faq__icon" aria-hidden="true">
<span class="faq__bar"></span>
<span class="faq__bar faq__bar--v"></span>
</span>
</button>
</h2>
<div class="faq__a" id="faq-a3" role="region" aria-labelledby="faq-q3">
<p class="faq__inner">通信時も保存時も、すべてのデータを暗号化しています。<br>第三者機関によるセキュリティ監査も、定期的に受けています。</p>
</div>
</div>
</div>
.faq {
--ink: #eeece6; /* 文字と線の色 */
--ground: #111111; /* 地の色。開いている間の棒の色になる */
--line: rgba(238, 236, 230, 0.14); /* 区切り線 */
border-top: 1px solid var(--line);
color: var(--ink);
}
.faq__item { border-bottom: 1px solid var(--line); }
.faq__head { margin: 0; }
.faq__q {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
width: 100%;
padding: 24px 0;
border: 0;
background: none;
color: inherit;
font: inherit;
font-size: 20px; /* font: inherit の後に書く(先だと打ち消される) */
font-weight: 700;
line-height: 1.5;
letter-spacing: 0.04em;
text-align: left;
cursor: pointer;
}
.faq__q:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 丸の線は box-shadow の inset で引く。border だと、線の太さぶん中の棒がずれる */
.faq__icon {
position: relative;
flex: none;
width: 40px;
height: 40px;
border-radius: 50%;
box-shadow: inset 0 0 0 1px var(--ink);
}
/* + は横棒と縦棒。縦棒を 90° 回すと横棒に重なって − になる */
.faq__bar {
position: absolute;
left: 50%;
top: 50%;
width: 14px;
height: 2px;
margin: -1px 0 0 -7px;
background-color: var(--ink);
}
.faq__bar--v { width: 2px; height: 14px; margin: -7px 0 0 -1px; }
/* 高さを 0 から中身の高さまで伸ばす枠。はみ出しを隠さないと、閉じていても答えが見える */
.faq__a {
height: 0;
overflow: hidden;
}
.faq__inner {
margin: 0;
padding: 0 64px 32px 0;
font-size: 18px;
line-height: 32px;
letter-spacing: 0.04em;
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const CLEAR = "rgba(0, 0, 0, 0)"; // 閉じている間の丸の中(塗らない)
document.querySelectorAll(".faq").forEach((faq) => {
const items = [...faq.querySelectorAll(".faq__item")];
const styles = getComputedStyle(faq);
// 色は CSS が正。GSAP は var(--name) のままでは補間できないので、値にして渡す
const ink = styles.getPropertyValue("--ink").trim();
const ground = styles.getPropertyValue("--ground").trim();
const toggles = items.map((item) => {
const answer = item.querySelector(".faq__a");
const inner = item.querySelector(".faq__inner");
const icon = item.querySelector(".faq__icon");
const bars = item.querySelectorAll(".faq__bar");
return gsap
.timeline({ paused: true })
// "auto" で渡してよい。GSAP が中身の高さを測り、px に直して補間する
.fromTo(answer, { height: 0 }, { height: "auto", duration: 0.5, ease: "power2.inOut" }, 0)
.fromTo(inner, { autoAlpha: 0, y: -8 }, { autoAlpha: 1, y: 0, duration: 0.35, ease: "power2.out" }, 0.12)
.fromTo(bars[1], { rotation: 0 }, { rotation: 90, duration: 0.4, ease: "power2.inOut" }, 0)
// 開いた項目は丸を塗り、棒を地の色にして明暗を反転する(色を足すより目立つ)
.fromTo(icon, { backgroundColor: CLEAR }, { backgroundColor: ink, duration: 0.3, ease: "none" }, 0)
.fromTo(bars, { backgroundColor: ink }, { backgroundColor: ground, duration: 0.3, ease: "none" }, 0);
});
// 動きを減らす設定では、ほぼ一瞬で切り替える
if (reduceMotion) toggles.forEach((toggle) => toggle.timeScale(20));
let current = -1; // 開いている項目(-1 は無し)
items.forEach((item, i) => {
item.querySelector(".faq__q").addEventListener("click", () => {
current = current === i ? -1 : i; // 開いている項目をもう一度押したら閉じる
toggles.forEach((toggle, k) => (k === current ? toggle.play() : toggle.reverse()));
items.forEach((it, k) => {
it.querySelector(".faq__q").setAttribute("aria-expanded", String(k === current));
});
});
});
// 幅が変われば答えの高さも変わる。いったん閉じた形に戻してから、測り直させる
window.addEventListener("resize", () => {
toggles.forEach((toggle) => {
const progress = toggle.progress();
toggle.progress(0).invalidate().progress(progress);
});
});
});