デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
質問を押すと、その答えが下へ開き、後ろに続く質問はそのぶん押し下げられます。答えは、はみ出しを隠した要素の中に入れてあり、その height を0から中身の高さまで伸ばしているだけです。下の項目は通常のレイアウトの流れで動くので、位置を計算する処理はありません。
高さは0.5秒の power2.inOut。答えの文は、高さが伸び始めてから少し遅れて現れるようにしました。高さと同時に動かすと、まだ狭い要素の中で文が動いて読みにくくなります。右端の + は縦棒が90°回って − になり、線だけだった丸が文字の色で塗られて、明暗が反転します。
高さの行き先には auto を渡しています。GSAP がその場で中身の高さを測って px に直すので、答えの長さが質問ごとに違っても同じ書き方で済みます。ただし、測った値はアニメーションの中に残り続けます。画面の幅が変わって答えの行数が増えるとその高さでは収まりきらないので、幅が変わったときは、いったん閉じた状態へ戻して測り直しています。
開くのは同時に1つだけです。押すたびに、開いている質問を逆再生し、押した質問を再生します。複数を同時に開けるようにしたいなら、閉じる処理を外すだけでは足りません。開いている質問を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);
background-color: var(--ground);
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;
word-break: auto-phrase; /* 和文を文節で折り返す(「か?」だけが次の行へ落ちない) */
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;
word-break: auto-phrase;
}
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);
});
});
});