本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
オーバーレイ

右下のヘルプの丸がパネルへ形を変えて広がり、よくある質問が吹き出しで順に出る

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
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 で決め、開くたびに測って広げます。質問が多いときは一覧だけがスクロールします。動きを減らす設定では、形の変化と吹き出しをほぼ一瞬にします。通販の商品のページ、予約や申し込みのフォーム、料金のページなど、問い合わせる前に自分で答えを探してほしい場面に向いています。

コード

HTML
<!-- 右下のヘルプ。丸のボタンが、そのまま形を変えてパネルになる -->
<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>

CSS
.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; } }

JavaScript
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);
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。