メガメニュー css 9選 — リンクの羅列からサイトの地図へ
メガメニュー css とは、上部ナビの項目ひとつから画面いっぱいの幅のパネルを開き、サイト全体の構造を一目で渡すメニューの作り方のことです。ここに置いた九つは、そのパネルだけでなく、多段展開・スクロールでの縮小・モバイルへの切り替え・検索・通知まで、メニューがサイトで担う仕事九つを全部、動くコードで並べました。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ホバーで開く全幅メガパネル
- 02 サブが右に滑る多段ドロップダウン
- 03 タブで切り替えるメガメニュー
- 04 スクロールで小さく折りたたむヘッダー
- 05 今の区画を知らせるスティッキーなサブナビ
- 06 ハンバーガーがXに折れるフルスクリーンオーバーレイ
- 07 右から滑り込むサイドドロワー
- 08 アイコンから入力欄へ広がる検索
- 09 既読が生きている通知バッジメニュー
並び順は人気順ではなく、メニューが背負う荷物が重くなる順です。最初の三つはデスクトップで情報を広げる方法です。行ひとつに吊るして丸ごと開き(01)、階層を横へ押し出し(02)、同じパネルの中で入れ替えます(03)。次の二つはスクロールと出会います。流れたら折りたたまれるヘッダー(04)と、区画について回り、今いる場所を告げるサブナビ(05)です。六番目と七番目は画面が狭くなったときの答えです。ハンバーガーが画面全体を覆う板へ折り(06)、引き出しが右から滑り込みます(07)。最後の二つはメニューに残った仕事、探すことと知らせることです。アイコンが入力欄へ広がり(08)、鐘がまだ読んでいない知らせを数えます(09)。最初の枠から最後の枠へ進むにつれ、メニューはリンクの目録からサイトの操縦席へ変わります。同じ軸の隣人はハンバーガーメニューのアニメーションとタブメニューの切り替えに、選択枠系はカスタムセレクトのドロップダウンにまとめてあります。
01ホバーで開く全幅メガパネル
上部の大メニューにポインターを乗せると画面いっぱいの幅のパネルが降りてきて、カテゴリ列3つと注目カード1枚を一緒に見せます。開く動作は6px の translateY と一コマのカットで終わり、列の見出しがその後で時差を持って浮かぶので、2秒のループで24コマ中18コマが動きます。製品群が3つ以上あるショッピングや SaaS の上部ナビに合います。
trigger.setAttribute('aria-haspopup', 'true');
trigger.setAttribute('aria-expanded', 'false');
function open() { stopDemo(); root.classList.add('is-open'); trigger.setAttribute('aria-expanded', 'true'); }
function close() { stopDemo(); root.classList.remove('is-open'); trigger.setAttribute('aria-expanded', 'false'); }
trigger.addEventListener('mouseenter', open);
trigger.addEventListener('focus', open);
trigger.addEventListener('click', function () { root.classList.contains('is-open') ? close() : open(); });
root.addEventListener('mouseleave', close);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
02サブが右に滑る多段ドロップダウン
第一段のリストで矢印付きの項目に触れると第二段が右から押し出され、その中の矢印付き項目が第三段まで続きます。下位の展開は CSS の :hover と :focus-within だけで決まるため、Tab キーで入っても同じ瞬間に開き、JS は最上位の開閉と aria-expanded だけを担います。カテゴリが2〜3層になるドキュメント・ヘルプサイトのナビに使います。
// 下位段は親項目(li)のすぐ右 — hover/:focus-within だけでも開く
.mls__l2,
.mls__l3 {
top: 0;
left: 100%;
opacity: 0;
visibility: hidden;
transform: translateX(12px);
transition: opacity $dur-quick $easing, transform $dur-quick $easing, visibility 0s linear $dur-quick;
}
.mls__i--parent:hover > .mls__l2,
.mls__i--parent:focus-within > .mls__l2,
.mls__i--parent:hover > .mls__l3,
.mls__i--parent:focus-within > .mls__l3 {
opacity: 1;
visibility: visible;
transform: translateX(0);
}
03タブで切り替えるメガメニュー
パネル内のタブを押すと同じ場所でカードの束が一コマで入れ替わり、押したタブだけが選択された状態で読まれます。入れ替えは二つの束を混ぜない steps(1, end) のカットで切り分け、直後に新しいカードが translateY で時差ポップするので、カットだけの場合より動くコマ数が半分以上増えます。同じカテゴリをいくつかの角度で見せるストアのナビに合います。
function select(i) {
tabs.forEach(function (t, k) {
t.setAttribute('aria-selected', k === i ? 'true' : 'false');
t.tabIndex = k === i ? 0 : -1;
sets[k].hidden = k !== i;
if (k === i && !root.classList.contains('is-demo')) {
sets[k].classList.remove('is-pop');
void sets[k].offsetWidth;
sets[k].classList.add('is-pop');
}
});
}
04スクロールで小さく折りたたむヘッダー
本文がスクロールして流れると、空のバーの背景とロゴが一緒に小さくなり、その分だけ本文が上へ引き寄せられ、戻れば元の大きさに戻ります。高さを変える代わりに、空のバーは scaleY、中身は scale、本文は translateY で折るので、縮小一回のリフローは0で、判定は本文の一番上に置いた番兵が画面から外れたかどうかだけで済みます。一覧やマガジンのようにスクロールが長いページの上部に使います。
// 縮小は全部 transform なのでリフローは0のまま。
var ns = document.querySelector('.ns');
var feed = ns.querySelector('.ns__feed');
var head = ns.querySelector('.ns__head');
new IntersectionObserver(function (entries) {
head.classList.toggle('is-compact', !entries[0].isIntersecting);
}, { root: feed }).observe(ns.querySelector('.ns__sentinel'));
05今の区画を知らせるスティッキーなサブナビ
セクションが流れる間はカテゴリバーが所定位置に張り付き、画面にかかったセクションが変わると強調がその枠へ移ります。現在のセクションは、スクロール箱をルートにした IntersectionObserver が画面の中心に最も近い区画を選び、チップの aria-current を書き換えます。マニュアルやランディングのように1ページに複数セクションがある場所に合います。
var io = new IntersectionObserver(function (entries) {
var w = win.getBoundingClientRect();
var mid = w.top + w.height / 2;
var best = null, gap = Infinity;
entries.forEach(function (e) {
if (!e.isIntersecting) return;
var r = e.boundingClientRect;
var d = Math.abs((r.top + r.bottom) / 2 - mid);
if (d < gap) { gap = d; best = e.target; }
});
if (best) mark(best);
}, { root: win, threshold: 0.5 });
06ハンバーガーがXに折れるフルスクリーンオーバーレイ
3本の線が開いて X 字に折りたたまれ、画面全体を覆う板の上へリンクが1つずつ押し上がって現れます。板が開いている間、キーボードの焦点は板の中にだけ留まります。棒の変形は translateY と rotate の組で、リンクの到着はそれぞれ時差を持つので、第一印象が扉ではなく案内板になります。モバイルやポートフォリオのメインメニューに使います。
ov.addEventListener('keydown', function (e) {
if (e.key !== 'Tab' || links.length === 0) return;
var first = links[0], last = links[links.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus({ preventScroll: true });
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus({ preventScroll: true });
}
});
07右から滑り込むサイドドロワー
ボタンを押すと右端からパネルが滑り込み、残りの画面は暗くなって触れなくなり、閉じると焦点は押したボタンへ戻ります。施錠は inert 属性ひとつです。本文に inert を付けると、後ろのリンクは目には残っても Tab とスクリーンリーダーが届かなくなり、引き出しを閉めれば両側が元に戻ります。カートやアカウントメニューのように引き出しで開く画面に合います。
function setOpen(on) {
root.classList.toggle('is-open', on);
page.inert = on; // 開いている間、本文はフォーカスを失う
page.setAttribute('aria-hidden', on ? 'true' : 'false');
drawer.inert = !on;
drawer.setAttribute('aria-hidden', on ? 'false' : 'true');
openBtn.setAttribute('aria-expanded', on ? 'true' : 'false');
(on ? closeBtn : openBtn).focus({ preventScroll: true });
}
08アイコンから入力欄へ広がる検索
虫眼鏡を押すとカプセル型の入力欄がアイコンの場所から広がり、数文字入れるとジャンプリンクがその下へ細く続きます。広げるときに幅を伸ばす代わりに、入力欄は常に全幅のまま clip-path: inset で右から覆われており、開いてもレイアウトの計算は起きません。上部の余白が惜しいドキュメント・ブログのヘッダーに合います。
$clip-shut: inset(0 calc(100% - 40px) 0 0 round 999px); // アイコンの40px だけ見える
$clip-open: inset(0 0 0 0 round 999px); // 全部見える
.search {
clip-path: $clip-shut;
transition: clip-path $dur-base $easing;
}
.frame.is-open .search { clip-path: $clip-open; }
09既読が生きている通知バッジメニュー
鐘に付いた数字が未読の通知数を数え、パネルで項目を読むと点が消え、数字もその瞬間に減っていきます。バッジの数字は三つ重ねて置き steps(1, end) で一コマに入れ替えるので、数字が半透明に重なる瞬間はなく、画面の外の aria-live 領域が新しい数を読み上げます。アプリ上部の通知センターに使います。
function sync() {
var n = unread().length;
root.classList.toggle('is-clear', n === 0);
badge.setAttribute('data-n', String(n));
live.textContent = live.dataset.fmt.replace('{n}', String(n));
}
どこで壊れるか — 落とし穴
今回は三か所で手が止まりました。
一つ目は 02 の幅です。320px の電話で三つの段を 112・96・84px に縮めても、本文の幅は 329px と測られました。原因はスライドそのものでした。下位の段は親項目の left: 100% の位置に吊るされ、スライド中の親の transform: translateX(12px) は絶対配置の子にも掛かるため、L2 と L3 が同時に動く瞬間、静止位置より最大 24px 余分に外へ出ます。メディアクエリで幅を 108・92・80px に下げ、12.8 + 280 + 24 = 316.8px に収めました。開いた一瞬だけはみ出す値は、レイアウトの数字を読むより目で一度見なければ捕まえられません。絶対配置のリストは親の高さ計算からも外れるので、縦の余白も根元に確保しておくのが安全です。同じ確認は 480px の舞台でも 320px の電話でも別々に走らせました。舞台で大丈夫でも電話で壊れることがこの稿で二度ありました。
二つ目は代表場面です。一覧の poster は前のコマと最も違うコマを選ぶので、開いて閉じるパネルでは、開いた瞬間になかった見出しやカードが閉じる瞬間には入っている分、閉じる側が勝ちやすくなります。最初に閉じをゆっくりフェードさせても変わりませんでした。このサイトの測定は、画素の最大チャンネル差が 12 を超えたら動いたと数えるのであって、クリーム色のパネルがインクの舞台の上では、アルファが半分に下がっても差がその敷居を超え続け、面積が半分に縮まらなかったのです。そこで閉じを opacity ではなく clip-path: inset のワイプに変え、一コマが受け持つ面積を割りました。折る方向は上から下に決めました。パネルが上部の行に吊るされているので、吊られた側から順に下へ沈み込む見た目が自然だったためです。01・03・09 の poster はこの方法で開いた場面を取り戻しました。九枚の poster は最後まで必ず目で確かめます。数値が全部揃っていても、選ばれた一枚が主役を写しているかどうかは機械には測れないからです。実際、修正の前は 02 の poster が閉じた後の空っぽの背景を写していました。
三つ目は、そのワイプが 09 で最初まったく動かなかったことです。パネルに掛かったアニメーションのひとつが steps(1, end) のタイミングを使っており、同じキーフレームに置いた clip-path まで階段に切られて、ワイプが一コマのカットに縮んでいました。clip-path をイージングの掛かった別のアニメーションへ移して初めて、コマごとに少しずつ折りたたまれました。階段は値を切り分ける道具であって、値を撫でる道具ではありません。同じ理由で、時差を付ける遅延は animation: の一括指定が消してしまうので、個別の animation-* プロパティに書く習慣が今回も答えになりました。コマのタイミングを決めるときは、24コマの標本が 4.1667% 刻みで降ってくることも頭に置きます。キーフレームの区切り目をこの刻みの間に置くか、刻みの上に置くかで、一コマが受け持つ変化の大きさが変わるからです。この稿の 8.4% や 74.9% といった端数はすべて、その刻みの上に区切り目を載せるために選んだ値です。九つのフォルダを開く圧縮のパスワードは kes5pp8b で、上に挙げた数値はすべて run フォルダの 측정.json にそのまま残っています。
アクセシビリティ
九つとも prefers-reduced-motion: reduce では自動再生を止め、それぞれの静止した場面を残します。01・02・03 はパネルが開いたまま立ち、04 は大きなヘッダーに戻り、05 はカテゴリバーが見出しの下の定位置に戻って止まります。06 は板が開いて棒が X 字に折れた一枚を見せ、07 は引き出しが開いた場面を、08 は入力欄が広がった場面を、09 は未読の三行をそのまま残します。扉はアニメーションですが、扉の状態は値なので、動きを消しても情報は減りません。開閉の判定はすべて aria-expanded に書かれ、aria-haspopup はスクリプトが最初に一度だけ付けます。焦点を戻す五つのデモはどれも focus({ preventScroll: true }) を使います。このデモたちはホームの一覧に iframe で埋め込まれているため、ガードのない focus が親ページまでスクロールを引っ張るからです。
文字のコントラストは、WCAG の相対輝度の式にこの稿の組み合わせを入れて、それぞれ手計算で求めました。
| 使う場所 | 文字 | 背景 | 比率 |
|---|---|---|---|
| 01・03・08 本文のインク / 白いパネル | #17141a |
#ffffff |
18.24:1 |
| 01 注目カードの白 / 青 | #ffffff |
#2f6df6 |
4.53:1 |
| 02・07・09 インク / クリームのパネル | #17141a |
#fff7e6 |
17.11:1 |
| 04 インク / 黄色の舞台 | #17141a |
#ffd23f |
12.63:1 |
| 05 強調チップのクリーム / インク | #fff7e6 |
#17141a |
17.11:1 |
| 06 大きなリンクのクリーム / オーバーレイ | #fff7e6 |
#241f2d |
15.05:1 |
| マウス | キーボード | 状態の居場所 | |
|---|---|---|---|
| 01 | トリガーに乗せる・押す | フォーカスで開き、Esc で閉じる | トリガーの aria-expanded |
| 02 | 矢印付きの項目に乗せる | Tab で下位に入り、Esc で全部閉じる | 各親ボタンの aria-expanded |
| 03 | タブを押す | 左右矢印でタブ移動、Esc で閉じる | タブの aria-selected、パネルの hidden |
| 04 | 本文をスクロールする | スクロール箱そのものがフォーカスを持つ | ヘッダーの .is-compact クラス |
| 05 | セクションを流す | チップのアンカーで区画へ移動 | チップの aria-current |
| 06 | ボタンを押す | Tab が板の中を巡り、Esc で閉じる | 板の aria-modal、ボタンの aria-expanded |
| 07 | ボタン・オーバーレイ・閉じるを押す | 本文は inert、Tab は引き出しの中、Esc で閉じる |
引き出しと本文の aria-hidden |
| 08 | 虫眼鏡を押して入力する | Esc で畳み、焦点はボタンへ戻る | ボタンの aria-expanded |
| 09 | 鐘・行・すべて既読を押す | 最初の未読行へ焦点、Esc で閉じる | aria-live 領域の数の一文 |
判定の基準はMDN の aria-haspopupとIntersectionObserver の文書に従いました。同じ軸の隣人には浮かぶドックナビゲーションとスティッキーヘッダー、サイドドロワーの切り替えがあります。
FAQ
メガメニューを使うべきでないときはどんなときですか
項目が七つを超えないと分かっているときです。パネルの利点は一度に見せる地図ですが、項目が少ないと地図は目録より遅くなります。その場合は 02 の多段ドロップダウン、フォームの中ならカスタムセレクトのドロップダウンのほうが早い答えです。語りたい量ではなく、読む人の地図の大きさで決めてください。
ホバーとクリックは何で決めればいいですか
パネルが開いたときに画面の半分以上を覆うならクリック、それより小さければホバーで構いません。ホバーは通りすがりにも開く分、他人の操作を盗みやすく、01 のように閉じを、ポインターが画面の外へ出る瞬間に結び付けないと、半開きの状態が残ります。01 はフォーカスとクリックも開く条件に入れており、タッチとキーボードを別の部品なしで支えています。
モバイルではこのパターンをどう変えますか
広げることを、覆うことに着せ替えます。デスクトップのメガパネルは、狭い画面では 06 のフルスクリーンか 07 のドロワーに変わるのが普通で、08 の検索はアイコンから始まる構造のまま狭い幅とよく合います。付け替えても残すのは動きではなく、aria-expanded・inert・トリガーへの焦点復帰という状態の約束です。約束が同じなら、画面が狭くなっても同じメニューです。