デモ
デモを読み込めませんでした。
仕様
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
線端の丸い、同じ長さの線を 3 本、丸の中央に重ねて置き、上下の線を 8px ずつ(線の太さ 2px と間 6px)ずらして、ハンバーガーにします。
開くときは、まず 0.2 秒で上下の線を中央へ寄せ、中の線は横に縮めながら消します。続けて 0.2 秒後から、上下の線を +45° と −45° に回します(0.35 秒、back.out(2))。寄せてから回すと、× の交点がぶれません。閉じるときは、同じタイムラインを逆再生します。クリックのたびに再生と逆再生を切り替え、aria-expanded も書き換えます。
開いているあいだは、丸と線の明暗を入れ替えます(丸を文字の色で塗り、線を地の色にする)。× だけより、開いていることがはっきり伝わります。
ホバーでは、丸の外に、ぼかしの無い影で薄い輪を出します。こちらも再生と逆再生で出し入れします。丸の塗りは開閉が使っているので、ホバーでは触りません。
寸法は、3 本線を幅 24px・線 2px・間 6px、ボタンを 48px の丸(1px の線)にしています。このデモは、見やすいように比率はそのままで 4 倍に拡大しています。スマホのナビゲーションや、ドロワーの開閉ボタンに向いています。
コード
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="site-nav" aria-label="メニューを開く">
<!-- 線は飾りなので読み上げから外す。状態は aria-expanded、名前は aria-label が持つ -->
<span class="menu-toggle__bar" aria-hidden="true"></span>
<span class="menu-toggle__bar" aria-hidden="true"></span>
<span class="menu-toggle__bar" aria-hidden="true"></span>
</button>
<nav id="site-nav" class="site-nav" aria-label="メインメニュー" hidden>
<ul>
<li><a href="/works/">制作実績</a></li>
<li><a href="/service/">サービス</a></li>
<li><a href="/contact/">お問い合わせ</a></li>
</ul>
</nav>
.menu-toggle {
--ink: #eeece6; /* 線と枠の色 */
--ground: #111111; /* 地の色。開いている間の線の色になる */
--ring: 4px; /* ホバーで出る輪の太さ */
--ring-color: rgba(238, 236, 230, 0.2); /* 輪の色。--ink と別に持つ(色の形式に関わらず薄くできる) */
--step: 8px; /* 上下の線を中央からずらす量(線 2px + 間 6px) */
position: relative;
width: 48px;
height: 48px;
padding: 0;
border: 1px solid var(--ink);
border-radius: 50%;
background-color: transparent;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
/* 3 本とも中央に重ねて置き、上下だけ --step ぶんずらす。× にするときは中央へ戻すだけで済む */
.menu-toggle__bar {
position: absolute;
left: 50%;
top: 50%;
width: 24px;
height: 2px;
margin: -1px 0 0 -12px;
border-radius: 1px; /* 線端を丸くする */
background-color: var(--ink);
}
.menu-toggle__bar:first-of-type { transform: translateY(calc(var(--step) * -1)); }
.menu-toggle__bar:last-of-type { transform: translateY(var(--step)); }
.menu-toggle:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
/* 開いている間だけ出すメニュー。位置や見た目はサイトに合わせて書き換える */
.site-nav ul {
margin: 16px 0 0;
padding: 0;
list-style: none;
}
.site-nav a {
display: inline-block;
padding: 8px 0;
color: var(--ink, #eeece6);
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const CLEAR = "rgba(0, 0, 0, 0)"; // 閉じている間の丸の中(塗らない)と、輪の始まり
document.querySelectorAll(".menu-toggle").forEach((button) => {
const [upper, middle, lower] = button.querySelectorAll(".menu-toggle__bar");
const nav = document.getElementById(button.getAttribute("aria-controls"));
const styles = getComputedStyle(button);
// 寸法と色は CSS が正。GSAP は var(--name) のままでは補間できないので、値にして渡す
const step = parseFloat(styles.getPropertyValue("--step"));
const ring = parseFloat(styles.getPropertyValue("--ring"));
const ringColor = styles.getPropertyValue("--ring-color").trim();
const ink = styles.getPropertyValue("--ink").trim();
const ground = styles.getPropertyValue("--ground").trim();
const open = gsap
.timeline({ paused: true })
// 1. 上下の線を中央へ寄せ、中の線を消す
.fromTo(upper, { y: -step }, { y: 0, duration: 0.2, ease: "power2.in" }, 0)
.fromTo(lower, { y: step }, { y: 0, duration: 0.2, ease: "power2.in" }, 0)
.fromTo(middle, { scaleX: 1, opacity: 1 }, { scaleX: 0, opacity: 0, duration: 0.15, ease: "power2.in" }, 0)
// 2. 重なった 2 本を逆向きに回す。寄せてから回すと × の交点がぶれない
.fromTo(upper, { rotation: 0 }, { rotation: 45, duration: 0.35, ease: "back.out(2)" }, 0.2)
.fromTo(lower, { rotation: 0 }, { rotation: -45, duration: 0.35, ease: "back.out(2)" }, 0.2)
// 開いている間は明暗を反転する。× だけより「開いている」ことがはっきり伝わる
.fromTo(button, { backgroundColor: CLEAR }, { backgroundColor: ink, duration: 0.3, ease: "power1.inOut" }, 0.1)
.fromTo([upper, middle, lower], { backgroundColor: ink }, { backgroundColor: ground, duration: 0.3, ease: "power1.inOut" }, 0.1);
// ホバーの輪は、ぼかしなしの box-shadow で丸の外に出す。丸の地は開閉が使うので触らない
const hover = gsap
.timeline({ paused: true })
.fromTo(
button,
{ boxShadow: `0 0 0 0 ${CLEAR}` },
{ boxShadow: `0 0 0 ${ring}px ${ringColor}`, duration: 0.25, ease: "power2.out" }
);
// 動きを減らす設定では、ほぼ一瞬で切り替える
if (reduceMotion) {
open.timeScale(20);
hover.timeScale(20);
}
let isOpen = false;
button.addEventListener("click", () => {
isOpen = !isOpen;
isOpen ? open.play() : open.reverse(); // 閉じるのは同じタイムラインの逆再生
button.setAttribute("aria-expanded", String(isOpen));
button.setAttribute("aria-label", isOpen ? "メニューを閉じる" : "メニューを開く");
if (nav) nav.hidden = !isOpen;
});
const show = () => hover.play();
const hide = () => hover.reverse();
button.addEventListener("pointerenter", show);
button.addEventListener("pointerleave", hide);
button.addEventListener("focus", show);
button.addEventListener("blur", hide);
});