デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- 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 倍に拡大して見せています。よくある質問、仕様や注意書きの折りたたみ、スマホのフッターのメニューの開閉に向いています。
コード
<!-- 質問は見出しの中のボタン。開閉の状態は 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>
.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;
}
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);
});
});