デモ
デモを読み込めませんでした。
仕様
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ハンバーガーの3本線は、押すと真ん中の線が横に縮んで消え、中央へ寄った上下の線が × に交わります。同時にボタンの丸が塗りつぶされ、丸と線の明暗が入れ替わります。もう一度押せば、同じ道筋をたどって元に戻ります。
寄せる動きと回す動きは、順番に分けました。まず0.2秒で上下の線を中央に集め、そのあとで ±45°へ回します(0.35秒、back.out(2))。寄せきってから回すので、× の交わる点がぶれません。閉じるときは同じタイムラインを逆再生するだけで、クリックのたびに再生と逆再生を切り替え、aria-expanded も書き換えています。
寸法は、幅24pxの線を直径48pxの丸に収めた形で、丸は1pxの線で縁取っています。上下の線は、線の太さと線のあいだの間隔を足した分だけずらしてあります。このデモでは、その比率を保ったまま拡大して見せています。
明暗を入れ替えるのは、× の形だけより、メニューが開いていることがはっきり伝わるからです。丸の塗りをこの切り替えに使っているので、ホバーでは塗りに触れず、ぼかしのない box-shadow で丸の外側に薄い輪を表示することにしました。この輪も、再生と逆再生で出入りさせています。
コード
<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,
.site-nav {
--ink: #eeece6; /* 線・枠線・文字の色 */
--ground: #111111; /* 背景色。開いている間の線の色にもなる */
}
.menu-toggle {
--ring: 4px; /* ホバーで表示する輪の太さ */
--ring-color: #3d3d3c; /* 輪の色。背景色に線の色を 2 割まぜた、透けない色(透ける色だと、明るいページでは輪が見えない) */
--step: 8px; /* 上下の線を中央からずらす量(線 2px + 間 6px) */
position: relative;
width: 48px;
height: 48px;
padding: 0;
border: 1px solid var(--ink);
border-radius: 50%;
background-color: var(--ground);
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 {
width: fit-content;
margin-top: 16px;
padding: 8px 24px;
border-radius: 12px;
background: var(--ground);
}
.site-nav ul {
margin: 0;
padding: 0;
list-style: none;
}
.site-nav a {
display: inline-block;
padding: 8px 0;
color: var(--ink);
white-space: nowrap;
}
.site-nav a:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
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: ground }, { 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);
});