デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
設定のボタンの中で、8 枚歯の大きい歯車と 6 枚歯の小さい歯車が噛み合っています。重ねると大きい歯車が 1 歯(45°)だけ回り、押すと回り出してメニューが開き、止まるときに少し行き過ぎてから戻ります。もう一度押すと、逆向きに半周回ってメニューが閉じます。小さい歯車は、大きい歯車の角度に 8 ÷ 6 を掛けた角度だけ逆向きに回るので、1.33 倍の速さで回り、歯がいつも噛み合ったまま動きます。
2 つの歯車は、歯数の半分を半径にした基準の円どうしが接する距離に置きます。大きい歯車の歯を小さい歯車へ向けたとき、小さい歯車は歯と歯の間を大きいほうへ向ける角度から始めます。歯は根元と先を円弧、側面を直線で描き、歯底を歯の高さより少し深くして、歯先が相手の歯底に触れないようにしてあります。この形で大きい歯車の 1 歯ぶんを細かく刻んで回し、どの角度でも 2 つの形が重ならず、隙間が残ることを数値で確かめています。
押したときは、90° を 0.35 秒の power2.in で加速し、残りを back.out(1.4) で止めます。power2.in の終わりの速さは平均の 3 倍、back.out(1.4) の出だしは平均の 4.4 倍なので、止める側の長さを「残りの角度 × 4.4 × 0.35 ÷ 270」にすると、加速から減速へ速さが途切れずにつながります。止める角度はいつも 45° の倍数にします。歯は歯ごとに同じ形なので、止まった見た目はいつも最初と同じ並びになり、半端な角度で止まって見えることがありません。
ボタンは aria-expanded でメニューの開閉を伝え、名前は「設定」のまま変えません。歯車は飾りなので読み上げから外します。Esc・メニューの外を押す・Tab でメニューから抜ける、のどれでも閉じます。キーボードで焦点が入ったときも、重ねたときと同じく 1 歯だけ回ります。タッチでは押すたびに pointerenter も届くので、重ねたときの動きはマウスとペンだけにしています。動きを減らす設定では回さず、メニューだけをすぐ開閉します。
1 本の矢印が回り続ける更新のアイコンと違い、2 つの部品が歯数の比で速さを変えて回るので、仕組みが動いている手触りが出ます。歯数を変えるときは、基準の円の半径と置く距離、小さい歯車の始めの角度を同じ決まりで出し直します。ヘッダーの設定ボタン、管理画面のツールバー、アプリの環境設定の入口に向いています。
コード
<!-- 設定のボタンと、押すと開くメニュー。歯車は飾りなので読み上げから外し、名前は aria-label が持つ -->
<div class="settings">
<button class="settings__button" type="button" aria-label="設定" aria-expanded="false" aria-controls="settings-menu">
<!-- data-teeth は大・小の歯数、data-align は大の中心から見た小の中心の向き(度) -->
<svg class="settings__icon" viewBox="-8.4 -13.7 25 22" data-teeth="8 6" data-align="-35" aria-hidden="true" focusable="false">
<path class="settings__big" transform="rotate(-35)" d="M5.39 -1.16L8 -0.88A8.05 8.05 0 0 1 8 0.88L5.39 1.16A5.51 5.51 0 0 1 4.63 2.99L6.28 5.03A8.05 8.05 0 0 1 5.03 6.28L2.99 4.63A5.51 5.51 0 0 1 1.16 5.39L0.88 8A8.05 8.05 0 0 1 -0.88 8L-1.16 5.39A5.51 5.51 0 0 1 -2.99 4.63L-5.03 6.28A8.05 8.05 0 0 1 -6.28 5.03L-4.63 2.99A5.51 5.51 0 0 1 -5.39 1.16L-8 0.88A8.05 8.05 0 0 1 -8 -0.88L-5.39 -1.16A5.51 5.51 0 0 1 -4.63 -2.99L-6.28 -5.03A8.05 8.05 0 0 1 -5.03 -6.28L-2.99 -4.63A5.51 5.51 0 0 1 -1.16 -5.39L-0.88 -8A8.05 8.05 0 0 1 0.88 -8L1.16 -5.39A5.51 5.51 0 0 1 2.99 -4.63L5.03 -6.28A8.05 8.05 0 0 1 6.28 -5.03L4.63 -2.99A5.51 5.51 0 0 1 5.39 -1.16ZM2.2 0A2.2 2.2 0 1 0 -2.2 0A2.2 2.2 0 1 0 2.2 0Z" />
<!-- 小は、2 つの基準の円が接する距離(12.25)に置く。歯と歯の間を大へ向けた角度から回す -->
<g transform="translate(10.03 -7.03)">
<path class="settings__small" transform="rotate(115)" d="M3.61 -1.05L6.23 -0.92A6.3 6.3 0 0 1 6.23 0.92L3.61 1.05A3.76 3.76 0 0 1 2.72 2.6L3.91 4.94A6.3 6.3 0 0 1 2.32 5.86L0.9 3.65A3.76 3.76 0 0 1 -0.9 3.65L-2.32 5.86A6.3 6.3 0 0 1 -3.91 4.94L-2.72 2.6A3.76 3.76 0 0 1 -3.61 1.05L-6.23 0.92A6.3 6.3 0 0 1 -6.23 -0.92L-3.61 -1.05A3.76 3.76 0 0 1 -2.72 -2.6L-3.91 -4.94A6.3 6.3 0 0 1 -2.32 -5.86L-0.9 -3.65A3.76 3.76 0 0 1 0.9 -3.65L2.32 -5.86A6.3 6.3 0 0 1 3.91 -4.94L2.72 -2.6A3.76 3.76 0 0 1 3.61 -1.05ZM1.5 0A1.5 1.5 0 1 0 -1.5 0A1.5 1.5 0 1 0 1.5 0Z" />
</g>
</svg>
</button>
<ul class="settings__menu" id="settings-menu" hidden>
<li><a href="/account/">プロフィール</a></li>
<li><a href="/account/notifications/">通知</a></li>
<li><a href="/logout/">ログアウト</a></li>
</ul>
</div>
.settings {
--ink: #eeece6; /* 大きい歯車とメニューの文字 */
--small: #98b1c4; /* 小さい歯車 */
--surface: #262626; /* ボタンの丸とメニューの面 */
--line: rgba(238, 236, 230, 0.14); /* メニューの縁 */
--size: 64px; /* ボタンの丸の大きさ */
--menu-width: 240px;
position: relative;
display: inline-block;
color: var(--ink);
}
.settings__button {
display: grid;
place-items: center;
width: var(--size);
height: var(--size);
padding: 0;
border: 0;
border-radius: 50%;
background: var(--surface);
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
.settings__button:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
/* 2 つの歯車が丸の中に収まる大きさ(viewBox は 25 × 22) */
.settings__icon {
width: calc(var(--size) * 0.78);
height: auto;
overflow: visible;
}
/* 軸の穴は、path の中の円を evenodd で抜いてある */
.settings__big {
fill: var(--ink);
fill-rule: evenodd;
}
.settings__small {
fill: var(--small);
fill-rule: evenodd;
}
/* 上から下へ開く。--open(0〜1)をスクリプトが動かす */
.settings__menu {
--open: 0;
position: absolute;
top: calc(100% + 16px);
left: 50%;
z-index: 10;
width: var(--menu-width);
margin: 0 0 0 calc(var(--menu-width) / -2 + var(--shift, 0px));
padding: 8px 0;
box-sizing: border-box;
list-style: none;
border-radius: 4px;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
clip-path: inset(0 0 calc((1 - var(--open)) * 100%) 0 round 4px);
translate: 0 calc((var(--open) - 1) * 8px);
}
.settings__menu[hidden] {
display: none;
}
.settings__menu a {
display: flex;
align-items: center;
height: 48px;
padding: 0 20px;
color: inherit;
font-size: 15px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.04em;
text-decoration: none;
white-space: nowrap;
}
.settings__menu a:hover,
.settings__menu a:focus-visible {
background: color-mix(in srgb, var(--ink) 8%, var(--surface));
outline: none;
}
import { gsap } from "gsap";
const BACK = 1.4; // 止まるときの行き過ぎ(back.out の強さ)
const OPEN = { dir: 1, total: 360, lead: 90, d1: 0.35 }; // 開く: 90° を加速し、残りを行き過ぎながら止める
const CLOSE = { dir: -1, total: 180, lead: 60, d1: 0.25 }; // 閉じる: 逆向きに半周
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".settings").forEach((box) => {
const button = box.querySelector(".settings__button");
const menu = box.querySelector(".settings__menu");
const icon = box.querySelector(".settings__icon");
const big = box.querySelector(".settings__big");
const small = box.querySelector(".settings__small");
const [bigTeeth, smallTeeth] = icon.dataset.teeth.split(" ").map(Number);
const align = Number(icon.dataset.align);
const tooth = 360 / bigTeeth; // 止める角度の刻み(大の 1 歯)。歯ごとに同じ形なので、止まった絵はいつも同じ並び
// 小の角度は、大の角度から歯数の比で決める(逆向きに、歯数の比だけ速く回る)
const state = { angle: 0 };
const draw = () => {
big.setAttribute("transform", `rotate(${align + state.angle})`);
small.setAttribute("transform", `rotate(${align + 180 - 180 / smallTeeth - (state.angle * bigTeeth) / smallTeeth})`);
};
draw();
let turn = null;
// 重ねたとき: 1 歯ぶんだけ回す。回っている最中は足さない
const nudge = () => {
if (reduceMotion || turn?.isActive()) return;
const to = Math.round(state.angle / tooth) * tooth + tooth;
turn = gsap.to(state, { angle: to, duration: 0.6, ease: "back.out(1.6)", onUpdate: draw });
};
// 押したとき: 加速して回り、行き過ぎてから 1 歯の倍数の角度に戻る。
// power2.in の終わりの速さ(平均の 3 倍)と back.out の出だし(平均の BACK + 3 倍)をそろえて 1 本につなぐ
const spin = ({ dir, total, lead, d1 }) => {
if (reduceMotion) return;
const from = state.angle + dir * lead;
const to = dir > 0 ? Math.ceil((state.angle + total) / tooth) * tooth : Math.floor((state.angle - total) / tooth) * tooth;
turn?.kill();
turn = gsap
.timeline({ onUpdate: draw })
.to(state, { angle: from, duration: d1, ease: "power2.in" })
.to(state, { angle: to, duration: ((BACK + 3) * Math.abs(to - from) * d1) / (3 * lead), ease: `back.out(${BACK})` });
};
let fold = null;
const isOpen = () => button.getAttribute("aria-expanded") === "true";
const setOpen = (open) => {
button.setAttribute("aria-expanded", String(open));
spin(open ? OPEN : CLOSE);
fold?.kill();
if (open) {
// ボタンの真下の中央に開く。画面の端からはみ出すときは、はみ出す分だけ内側へずらす
menu.hidden = false;
menu.style.setProperty("--shift", "0px");
const r = menu.getBoundingClientRect();
const room = document.documentElement.clientWidth - 8;
menu.style.setProperty("--shift", `${Math.max(8 - r.left, Math.min(0, room - r.right))}px`);
}
fold = gsap.to(menu, {
"--open": open ? 1 : 0,
duration: reduceMotion ? 0 : open ? 0.3 : 0.2,
ease: open ? "power2.out" : "power2.in",
onComplete: () => (menu.hidden = !open),
});
};
button.addEventListener("click", () => setOpen(!isOpen()));
// タッチでは、押すたびに pointerenter も届くので、重ねたときの動きはマウスとペンだけにする
button.addEventListener("pointerenter", (e) => e.pointerType !== "touch" && nudge());
button.addEventListener("focus", () => button.matches(":focus-visible") && nudge());
// Esc・メニューの外を押したとき・Tab でメニューから抜けたときに閉じる
box.addEventListener("keydown", (e) => {
if (e.key !== "Escape" || !isOpen()) return;
setOpen(false);
button.focus();
});
document.addEventListener("click", (e) => isOpen() && !box.contains(e.target) && setOpen(false));
box.addEventListener("focusout", (e) => isOpen() && !box.contains(e.relatedTarget) && setOpen(false));
});