デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- 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 色(青みの濃い灰 → くすんだ青 → くすんだ茶)にして浮かせています。色相より明るさで差を付けると、色の見え方が違う人にも見分けやすくなります。モバイルの作成ボタン(メモ・写真・スキャン)、地図の現在地やレイヤーの切り替え、チャットの添付に向いています。
コード
<!-- 画面の右下の作成ボタン(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>
.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; }
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);
});
});