デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
画面の右下の「?」の丸を押すと、丸がその場で角の丸い四角のパネルへ形を変えて広がり、「よくある質問」の見出しと、「送料はかかりますか」などの質問が吹き出しのように下から順に浮かびます。質問を押すと、その吹き出しが暗く反転し、すぐ下に答えの吹き出しが開いて、下の質問が押し下げられます。× を押すと、中身が先に消えてから、パネルが元の丸へ畳まれます。
形は、右下の角を止めたまま、幅・高さ・角の丸みを 0.5 秒の power3.inOut で補間します。高さだけを少し早く伸ばすので、丸は縦長の楕円を通ってから四角になります。隅から弾んで出るポップアップや、丸いボタンから小さなボタンが扇に飛び出すメニューと違い、押した丸そのものがパネルになるので、どこから開いたのかが途切れずに見えます。吹き出しは 0.08 秒ずつずらし、16px 下から 0.35 秒で浮かべます。
丸を scale で広げると、中の文字と角の丸み、縁の線まで一緒に伸び縮みします。幅と高さを補間し、中身はパネルの右下の角にそろえた、パネルと同じ大きさの入れ物に置きます。広がる縁から中身が現れるだけなので、文字は一度も伸びません。答えは高さを 0 から中身の高さへ開き、場所が空いてから吹き出しを浮かべるので、押し下げられる質問と重なりません。
丸はボタンにし、aria-expanded で開閉を伝えます。開くと焦点を最初の質問へ移し、Esc か × で閉じると丸へ焦点を戻します。ページを読みながら使う小さな窓なので、モーダルにはせず、焦点も閉じ込めません。外を押しても畳みます。質問はボタンと答えの領域の組にし、ほかの答えを閉じて、開いているのがいつも 1 つになるようにします。
パネルの大きさは CSS で決め、開くたびに測って広げます。質問が多いときは一覧だけがスクロールします。動きを減らす設定では、形の変化と吹き出しをほぼ一瞬にします。通販の商品のページ、予約や申し込みのフォーム、料金のページなど、問い合わせる前に自分で答えを探してほしい場面に向いています。
コード
<!-- 右下のヘルプ。丸のボタンが、そのまま形を変えてパネルになる -->
<div class="help">
<button class="help__launcher" type="button" aria-expanded="false" aria-controls="help-panel" aria-label="よくある質問を開く">?</button>
<section class="help__panel" id="help-panel" aria-labelledby="help-title" hidden>
<div class="help__head">
<h2 class="help__title" id="help-title">よくある質問</h2>
<p class="help__lead">お困りのことはありませんか</p>
<button class="help__close" type="button" aria-label="よくある質問を閉じる">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>
</button>
</div>
<!-- 質問はボタン、答えはすぐ下の領域。押すとその場で開き、ほかの答えは閉じる -->
<ul class="help__list">
<li class="help__item">
<button class="help__q" type="button" aria-expanded="false" aria-controls="help-a1">送料はかかりますか</button>
<div class="help__a" id="help-a1" hidden><p>¥5,000以上のご注文は送料無料です。それより少ないときは全国一律¥550です。</p></div>
</li>
<li class="help__item">
<button class="help__q" type="button" aria-expanded="false" aria-controls="help-a2">注文を取り消せますか</button>
<div class="help__a" id="help-a2" hidden><p>発送の準備に入る前なら、注文履歴の画面から取り消せます。</p></div>
</li>
<li class="help__item">
<button class="help__q" type="button" aria-expanded="false" aria-controls="help-a3">届くまで何日かかりますか</button>
<div class="help__a" id="help-a3" hidden><p>ご注文の翌日から2〜4日でお届けします。離島は数日多くかかります。</p></div>
</li>
<li class="help__item">
<button class="help__q" type="button" aria-expanded="false" aria-controls="help-a4">支払い方法を教えてください</button>
<div class="help__a" id="help-a4" hidden><p>クレジットカード・コンビニ払い・後払いからお選びいただけます。</p></div>
</li>
</ul>
<a class="help__chat" href="/contact/">チャットで問い合わせる</a>
</section>
</div>
.help {
--ink: #eeece6; /* 文字 */
--mute: #a8a6a0; /* 小さい補足の文字 */
--surface: #262626; /* 丸とパネルの地 */
--line: rgba(238, 236, 230, 0.14); /* 縁と区切りの罫線 */
--bubble: #eeece6; /* 吹き出しの地 */
--bubble-ink: #111111; /* 吹き出しの文字 */
--picked: #3a3a3a; /* 押した質問の地(明暗を反転する) */
--edge: 32px; /* 画面の右下からの間 */
position: fixed;
right: var(--edge); bottom: var(--edge); z-index: 20;
width: 56px; height: 56px;
border-radius: 28px;
overflow: hidden;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
color: var(--ink);
}
/* 丸のボタンと中身は、どちらも右下の角にそろえる。広がる縁から中身が現れる */
.help__launcher, .help__panel { position: absolute; right: 0; bottom: 0; }
.help__launcher { width: 56px; height: 56px; padding: 0; border: 0; border-radius: 50%; background: none; color: inherit; font: inherit; font-size: 24px; font-weight: 700; cursor: pointer; }
.help__panel[hidden] { display: none; }
.help__panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
box-sizing: border-box;
width: min(360px, calc(100vw - var(--edge) * 2));
height: min(488px, calc(100vh - var(--edge) * 2));
padding-bottom: 24px;
}
.help__head { position: relative; padding: 28px 24px 20px; box-shadow: inset 0 -1px 0 var(--line); }
.help__title { margin: 0; font-size: 18px; line-height: 1; letter-spacing: 0.02em; }
.help__lead { margin: 10px 0 0; color: var(--mute); font-size: 13px; line-height: 1; letter-spacing: 0.04em; }
/* アイコンだけのボタンは線の丸 */
.help__close { position: absolute; top: 24px; right: 20px; display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 50%; box-shadow: inset 0 0 0 1px var(--ink); background: none; color: inherit; cursor: pointer; }
.help__close svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 3.4; stroke-linecap: round; }
/* 質問は右寄せの吹き出し、答えは左寄せの吹き出し。はみ出したら一覧だけをスクロールする */
.help__list { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; margin: 0; padding: 20px 24px; overflow-y: auto; list-style: none; }
.help__item { display: flex; flex-direction: column; align-items: flex-end; width: 100%; }
.help__q {
height: 40px;
padding: 0 16px; border: 0;
border-radius: 16px 16px 4px 16px;
background: var(--bubble);
color: var(--bubble-ink);
font: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; white-space: nowrap;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.help__q[aria-expanded="true"] { background: var(--picked); color: var(--ink); }
.help__a { align-self: flex-start; width: min(264px, 100%); overflow: hidden; }
.help__a[hidden] { display: none; }
.help__a p { margin: 10px 0 0; padding: 12px 16px; border-radius: 4px 16px 16px 16px; background: var(--bubble); color: var(--bubble-ink); font-size: 13px; line-height: 1.7; letter-spacing: 0.04em; word-break: auto-phrase; }
.help__chat {
display: block;
margin: 0 24px;
height: 44px;
border-radius: 999px;
background: var(--ink);
color: #111111;
font-size: 14px; font-weight: 600; line-height: 44px; letter-spacing: 0.04em; text-align: center; text-decoration: none;
}
.help :focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.help__launcher:focus-visible { outline-offset: -5px; }
@media (max-width: 640px) { .help { --edge: 16px; } }
@media (prefers-reduced-motion: reduce) { .help__q { transition-duration: 0.01s; } }
import { gsap } from "gsap";
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduce ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const help = document.querySelector(".help");
const launcher = help.querySelector(".help__launcher");
const panel = help.querySelector(".help__panel");
const questions = [...panel.querySelectorAll(".help__q")];
const fades = panel.querySelectorAll(".help__head, .help__chat");
const items = panel.querySelectorAll(".help__item");
let motion;
let ball; // 丸の大きさ(開く前に測る)
const isOpen = () => launcher.getAttribute("aria-expanded") === "true";
// 丸のまま右下の角を止め、幅・高さ・角の丸みを補間してパネルの大きさへ広げる(拡大ではないので文字は伸びない)
function open() {
ball ??= { width: help.offsetWidth, height: help.offsetHeight, borderRadius: getComputedStyle(help).borderRadius }; // 畳む途中に開き直しても測り直さない
panel.hidden = false;
const size = { width: panel.offsetWidth, height: panel.offsetHeight }; // 中身の大きさは CSS で決める
launcher.setAttribute("aria-expanded", "true");
motion?.kill();
motion = gsap.timeline()
.to(launcher, { autoAlpha: 0, duration: 0.12, ease: "none" }, 0)
.to(help, { width: size.width, duration: 0.5, ease: "power3.inOut" }, 0)
.to(help, { height: size.height, borderRadius: 16, duration: 0.46, ease: "power3.inOut" }, 0) // 高さを少し先に伸ばす
.fromTo(fades, { opacity: 0 }, { opacity: 1, duration: 0.25, ease: "none" }, 0.35)
.fromTo(items, { y: 16, opacity: 0 }, { y: 0, opacity: 1, duration: 0.35, ease: "power3.out", stagger: 0.08 }, 0.4)
.timeScale(SPEED);
questions[0].focus({ preventScroll: true });
}
// 中身を先に消してから、丸へ畳む。答えは消えたあとに閉じておく
function close(returnFocus = true) {
if (!isOpen()) return;
launcher.setAttribute("aria-expanded", "false");
motion?.kill();
motion = gsap.timeline({ onComplete: () => { panel.hidden = true; questions.forEach((q) => answer(q, false, true)); } })
.to([fades, items], { opacity: 0, duration: 0.15, ease: "none" }, 0)
.to(help, { width: ball.width, duration: 0.5, ease: "power3.inOut" }, 0.08)
.to(help, { height: ball.height, borderRadius: ball.borderRadius, duration: 0.46, ease: "power3.inOut" }, 0.12)
.to(launcher, { autoAlpha: 1, duration: 0.15, ease: "none" }, 0.43)
.timeScale(SPEED);
if (returnFocus) launcher.focus(); // 閉じたら、丸のボタンへ焦点を戻す
}
// 答えは高さを 0 から中身の高さへ開き、場所が空いてから吹き出しを浮かべる
function answer(q, on, instant = false) {
const box = document.getElementById(q.getAttribute("aria-controls"));
const bubble = box.firstElementChild;
q.setAttribute("aria-expanded", String(on));
gsap.killTweensOf([box, bubble]);
if (instant) {
box.hidden = !on;
return gsap.set([box, bubble], { clearProps: "all" });
}
box.hidden = false;
if (on) {
gsap.timeline()
.fromTo(box, { height: 0 }, { height: "auto", duration: 0.4, ease: "power2.inOut" }, 0)
.fromTo(bubble, { y: 8, opacity: 0 }, { y: 0, opacity: 1, duration: 0.22, ease: "power2.out" }, 0.18)
.timeScale(SPEED);
} else {
gsap.to(box, { height: 0, duration: 0.3, ease: "power2.inOut", onComplete: () => (box.hidden = true) }).timeScale(SPEED);
}
}
launcher.addEventListener("click", open);
help.querySelector(".help__close").addEventListener("click", () => close());
questions.forEach((q) => {
q.addEventListener("click", () => {
const on = q.getAttribute("aria-expanded") !== "true";
questions.forEach((other) => other !== q && other.getAttribute("aria-expanded") === "true" && answer(other, false));
answer(q, on);
});
});
// Esc と、ヘルプの外を押したときに畳む(外を押したときは、焦点を押した先に任せる)
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && isOpen()) close();
});
document.addEventListener("click", (event) => {
if (isOpen() && !help.contains(event.target)) close(false);
});