本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
アイコン

設定の歯車のアイコンが 2 つ噛み合って回り、止まると軽く戻る

デモ

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

仕様

用途
アイコン
動作
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 つの部品が歯数の比で速さを変えて回るので、仕組みが動いている手触りが出ます。歯数を変えるときは、基準の円の半径と置く距離、小さい歯車の始めの角度を同じ決まりで出し直します。ヘッダーの設定ボタン、管理画面のツールバー、アプリの環境設定の入口に向いています。

コード

HTML
<!-- 設定のボタンと、押すと開くメニュー。歯車は飾りなので読み上げから外し、名前は 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>

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

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

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

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