本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ボタン

丸いボタンを押すと、スピードダイヤルのアクションが扇状に飛び出す

デモ

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

仕様

用途
ボタン
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

画面の右下の丸いボタン(FAB)を押すと、+ が 45° 回って × になり、画面の中身が薄い幕で覆われて、小さなアクションのボタンが円弧の上へ順に飛び出します。少し遅れて、それぞれの左にラベルが右から滑り込みます。もう一度押すと、同じタイムラインを逆にたどり、最後に出たものから順に FAB へ戻ります。

アクションの丸は FAB の中心の下に隠しておき、半径 136px の円弧の上(左 → 左上 → 真上)へ、0.45 秒の back.out(1.7) で動かします。少し行き過ぎてから止まるので、弾けて飛び出したように見えます。出る間隔は 0.06 秒で、開閉は 1 本のタイムラインの play と reverse だけで行います。

ラベルどうしが重ならないよう、半径は「真上のボタンのラベルの下端が、左上のボタンの上端より上」になる大きさにします(3 個なら FAB の直径の約 2.4 倍)。4 個以上にするなら、半径を広げるか、ラベルを出さずにアイコンだけにします。ラベルはボタンの中に置いて名前にし、動くのは中の丸だけにして、ラベルは止まる位置で滑り込ませます。

FAB には aria-expanded を付けます。閉じている間のアクションは visibility の hidden で隠すので、Tab で見えないボタンに移りません。Esc・幕の所を押したとき・焦点が外へ出たときにも閉じ、アクションを選んだら焦点を FAB に戻します。動きを減らす設定では、タイムラインを速めてほぼ一瞬で開閉します。

半径・角度の範囲・出る間隔・行き過ぎの強さで印象が変わります。幕は地の色の半透明にして、ラベルは墨色、アクションの丸は濃さの違う 3 色(青みの濃い灰 → くすんだ青 → くすんだ茶)にして浮かせています。色相より明るさで差を付けると、色の見え方が違う人にも見分けやすくなります。モバイルの作成ボタン(メモ・写真・スキャン)、地図の現在地やレイヤーの切り替え、チャットの添付に向いています。

コード

HTML
<!-- 画面の右下の作成ボタン(FAB)。押すと、アクションのボタンが円弧の上へ順に飛び出す -->
<div class="speed-dial">
  <!-- 中身を薄める幕。押すと閉じる -->
  <div class="speed-dial__scrim" aria-hidden="true"></div>
  <button class="speed-dial__fab" type="button" aria-label="新規作成" aria-expanded="false" aria-controls="speed-dial-actions">
    <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 5v14M5 12h14" /></svg>
  </button>
  <!-- 上から順に、円弧の左 → 左上 → 真上へ並ぶ。ラベルがボタンの名前になる -->
  <ul class="speed-dial__actions" id="speed-dial-actions">
    <li>
      <button class="speed-dial__action" type="button" data-action="text">
        <span class="speed-dial__label">テキスト</span>
        <span class="speed-dial__icon" aria-hidden="true">
          <svg viewBox="0 0 24 24" focusable="false"><path d="M4 20h4L19 9l-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg>
        </span>
      </button>
    </li>
    <li>
      <button class="speed-dial__action" type="button" data-action="voice">
        <span class="speed-dial__label">ボイスメモ</span>
        <span class="speed-dial__icon" aria-hidden="true">
          <svg viewBox="0 0 24 24" focusable="false"><rect x="9" y="3.5" width="6" height="11" rx="3" /><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21" /></svg>
        </span>
      </button>
    </li>
    <li>
      <button class="speed-dial__action" type="button" data-action="scan">
        <span class="speed-dial__label">スキャン</span>
        <span class="speed-dial__icon" aria-hidden="true">
          <svg viewBox="0 0 24 24" focusable="false"><path d="M4 8V5.5A1.5 1.5 0 0 1 5.5 4H8M16 4h2.5A1.5 1.5 0 0 1 20 5.5V8M20 16v2.5a1.5 1.5 0 0 1-1.5 1.5H16M8 20H5.5A1.5 1.5 0 0 1 4 18.5V16M4 12h16" /></svg>
        </span>
      </button>
    </li>
  </ul>
</div>

CSS
.speed-dial {
  --ink: #262626;          /* FAB・ラベルの地 */
  --tint-1: #4a5b66;       /* アクションの丸。上から順に、明るさの段で分ける */
  --tint-2: #688297;
  --tint-3: #a28767;
  --ink-open: #111111;     /* 開いている間の FAB(墨を少し濃くする) */
  --on-ink: #ffffff;       /* 墨の上のアイコンと文字 */
  --scrim: rgba(242, 241, 237, 0.82); /* 中身を薄める幕(地の色の半透明) */
  --fab: 56px;             /* FAB の直径 */
  --action: 44px;          /* アクションの直径 */
  --radius: 136px;         /* FAB の中心からアクションの中心まで(JS が読むので px で書く) */
  position: fixed;
  right: 24px;
  bottom: 24px;
  z-index: 100;
  width: var(--fab);
  height: var(--fab);
}

.speed-dial__scrim { position: fixed; inset: 0; background: var(--scrim); visibility: hidden; opacity: 0; }

.speed-dial__fab {
  position: relative;
  z-index: 2; /* アクションは FAB の下に隠しておき、そこから滑り出させる */
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background-color: var(--ink);
  color: var(--on-ink);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.speed-dial__fab svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; }

.speed-dial__actions { position: absolute; inset: 0; z-index: 1; margin: 0; padding: 0; list-style: none; }

/* アクションは円弧の上の止まる位置に置く(位置は JS が半径から決める)。動くのは中の丸だけ */
.speed-dial__action {
  position: absolute;
  left: calc((var(--fab) - var(--action)) / 2);
  top: calc((var(--fab) - var(--action)) / 2);
  width: var(--action);
  height: var(--action);
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  cursor: pointer;
  visibility: hidden; /* 閉じている間は、Tab でも移らない */
  -webkit-tap-highlight-color: transparent;
}

.speed-dial__icon { display: grid; place-items: center; width: 100%; height: 100%; border-radius: 50%; background: var(--tint); color: var(--on-ink); }
.speed-dial__actions li:nth-child(1) { --tint: var(--tint-1); }
.speed-dial__actions li:nth-child(2) { --tint: var(--tint-2); }
.speed-dial__actions li:nth-child(3) { --tint: var(--tint-3); }
.speed-dial__icon svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }

/* ラベルは丸の左に置く。transform は GSAP が使うので、縦の中央そろえは margin で */
.speed-dial__label {
  position: absolute;
  right: calc(100% + 8px);
  top: 50%;
  height: 28px;
  margin-top: -14px;
  padding: 0 12px;
  border-radius: 4px;
  background: var(--ink);
  color: var(--on-ink);
  font-size: 13px;
  font-weight: 600;
  line-height: 28px;
  letter-spacing: 0.06em;
  white-space: nowrap;
}

.speed-dial__fab:focus-visible,
.speed-dial__action:focus-visible .speed-dial__icon { outline: 2px solid var(--ink); outline-offset: 3px; }
.speed-dial__action:focus-visible { outline: none; }

JavaScript
import { gsap } from "gsap";

const STAGGER = 0.06; // アクションが順に出る間隔(秒)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

document.querySelectorAll(".speed-dial").forEach((root) => {
  const fab = root.querySelector(".speed-dial__fab");
  const plus = fab.querySelector("svg");
  const scrim = root.querySelector(".speed-dial__scrim");
  const actions = [...root.querySelectorAll(".speed-dial__action")];
  const icons = actions.map((action) => action.querySelector(".speed-dial__icon"));
  const labels = actions.map((action) => action.querySelector(".speed-dial__label"));
  // 色と半径は CSS が正。GSAP は var(--name) のままでは補間できないので、値にして渡す
  const styles = getComputedStyle(root);
  const radius = parseFloat(styles.getPropertyValue("--radius"));

  // 円弧の上の止まる位置(180° → 270°。左 → 真上)。アクションはそこに置き、中の丸を FAB の中心から動かす
  const spots = actions.map((_, i) => {
    const angle = Math.PI * (1 + (0.5 * i) / Math.max(1, actions.length - 1));
    return { x: Math.round(radius * Math.cos(angle)), y: Math.round(radius * Math.sin(angle)) };
  });
  actions.forEach((action, i) => gsap.set(action, spots[i]));

  // 開く動き。閉じるときは同じタイムラインを逆にたどり、最後に出たものから順に戻る
  const dial = gsap
    .timeline({ paused: true })
    .to(plus, { rotation: 45, duration: 0.4, ease: "back.out(2)" }, 0) // + を回して × に
    .to(fab, { backgroundColor: styles.getPropertyValue("--ink-open").trim(), duration: 0.25, ease: "none" }, 0)
    .fromTo(scrim, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.3, ease: "none" }, 0)
    // back.out の行き過ぎで、弾けて飛び出したように見える
    .fromTo(actions, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.45, ease: "back.out(1.7)", stagger: STAGGER }, 0.05)
    .fromTo(icons, { x: (i) => -spots[i].x, y: (i) => -spots[i].y }, { x: 0, y: 0, duration: 0.45, ease: "back.out(1.7)", stagger: STAGGER }, 0.05)
    // ラベルは、丸が止まるころに右から滑り込ませる
    .fromTo(labels, { autoAlpha: 0, x: 12 }, { autoAlpha: 1, x: 0, duration: 0.3, ease: "power2.out", stagger: STAGGER }, 0.22)
    .timeScale(reduceMotion ? 20 : 1); // 動きを減らす設定では、ほぼ一瞬で開閉する

  // FAB は押すたびに、縮んでから弾んで戻る
  const tap = gsap
    .timeline({ paused: true })
    .to(fab, { scale: 0.9, duration: 0.08, ease: "power2.out" })
    .to(fab, { scale: 1, duration: 0.35, ease: "back.out(3)" });

  const isOpen = () => fab.getAttribute("aria-expanded") === "true";
  const toggle = (open) => {
    fab.setAttribute("aria-expanded", String(open));
    if (open) dial.play();
    else dial.reverse();
  };

  fab.addEventListener("click", () => {
    if (!reduceMotion) tap.restart();
    toggle(!isOpen());
  });
  scrim.addEventListener("click", () => toggle(false));
  // アクションを選んだら閉じて、焦点を FAB に戻す(閉じると隠れるボタンに、焦点を残さない)
  actions.forEach((action) =>
    action.addEventListener("click", () => {
      toggle(false);
      fab.focus();
      root.dispatchEvent(new CustomEvent("speeddial", { bubbles: true, detail: action.dataset.action })); // ここで各アクションを呼ぶ
    })
  );
  // Esc でも閉じて FAB に戻る。焦点がダイヤルの外へ出たときも閉じる
  root.addEventListener("keydown", (event) => {
    if (event.key !== "Escape" || !isOpen()) return;
    toggle(false);
    fab.focus();
  });
  root.addEventListener("focusout", (event) => {
    if (isOpen() && !root.contains(event.relatedTarget)) toggle(false);
  });
});

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

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