GODRICH

【JS最小・CSSだけ】css ハンバーガーメニュー アニメーション9選

css ハンバーガーメニュー アニメーション は、3本線のアイコンをクリックした瞬間に何が起こるかを決める短い動きです。この記事では9種類を選び、CSS の transform と opacity だけで実装しました(JS はクリックを受け取る数行だけ)。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

グリッドの9つは iframe の中で2秒周期に自動で開閉を繰り返すだけで、クリックしても反応しません。動きを見比べるための「眺める」デモで、実際に押して確かめたいときは下にある項目ごとのデモを開いてください。並び順は効果の種類ではなく、実務でよく使う場面の順です。最初の4つはヘッダーに置く定番のアイコン変形(01〜04)、次の3つは開いた後のパネルやリンクの見せ方(05〜07)、最後の2つは背景側に演出を足すものです(08〜09)。9つとも JavaScript はクリックを受け取る数行だけで、開閉そのものは CSS の transition と transform が担っています。

013本線がXに変形

上下の線がそれぞれ45度ずつ回転して重なりXになり、中央の線はscaleXとopacityで同時に消えます。300msのspringイージング以外に特殊な仕掛けがないので、ヘッダーの基本ボタンとして最初に選ぶならこの形です。

300ms45deg가운데 페이드
.bar {
  position: absolute; left: 50%; top: 50%;
  width: clamp(20px, 6.4vmin, 26px); height: 3px; border-radius: 2px;
  background: $color;
  transition: transform $duration $easing, opacity $duration $easing;
}
.bar1 { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }

.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }

02回転しながらX

線がXに変形するのと同時に、アイコン全体が180度回ります。ease-popで終わりに少しだけ弾みを付けているので、強調したいモバイルナビの開閉ボタンに向いています。

180deg350ms역회전
$duration: 350ms;         // アイコンが回る時間
$easing: $ease-pop;       // 最後に少し弾む

.burger {
  transform: rotate(0deg);
  transition: transform $duration $easing;
}

.stage.is-open .burger { transform: rotate(180deg); }
.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }

03矢印に変形

右端を軸に3本の線が閉じていくと、開くボタンではなく戻る矢印のような形になります。中央の線は消えずscaleX(.7)で少し縮むだけなので、戻る操作を兼ねるメニューボタンに使えます。

250ms오른쪽 화살표각도 30deg
$duration: 250ms;          // 矢印に狭まる時間
$easing: $ease-spring;

// 右端を軸に回転させ、左端が閉じると "<" の形になる
.bar {
  transform-origin: right center;
}
.bar1 { transform: translate(50%, -50%) translateY(-7px) rotate(0deg); }
.bar2 { transform: translate(50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(50%, -50%) translateY(7px) rotate(0deg); }

.stage.is-open .bar1 { transform: translate(50%, -50%) translateY(-3px) rotate(-35deg); }
.stage.is-open .bar2 { transform: translate(50%, -50%) scaleX(.7); opacity: 1; }
.stage.is-open .bar3 { transform: translate(50%, -50%) translateY(3px) rotate(35deg); }

04中央線がスライドアウト

開くと中央の線だけが横に抜けて消え、そのあとで上下の線がXに重なります。keyframeでは中央線が8%地点、上下線が15%地点と、少しだけタイミングをずらしているのがこの項目の要点です。

오른쪽으로280ms왼쪽으로
$duration: 280ms;         // 中央線が抜ける時間

.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) translateX(140%); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }

// 中央線が先に抜け、そのあと上下線がXに重なる。閉じるときは逆順
@keyframes bar2-loop {
  0%   { transform: translate(-50%, -50%) translateX(0); opacity: 1; }
  8%   { transform: translate(-50%, -50%) translateX(140%); opacity: 0; }
  78%  { transform: translate(-50%, -50%) translateX(140%); opacity: 0; }
  85%  { transform: translate(-50%, -50%) translateX(0); opacity: 1; }
  100% { transform: translate(-50%, -50%) translateX(0); opacity: 1; }
}

05全画面オーバーレイメニュー

ボタンを押すと画面全体を覆う色パネルが下から600msかけてせり上がり、メニューが現れます。stageにoverflow: hiddenをかけているので、パネルがせり上がっている間もページの他の部分は動きません。

400ms위로좌우 슬라이드
$duration: 600ms;          // パネルがせり上がる時間

.stage { position: relative; overflow: hidden; }

.panel {
  position: absolute; inset: 0; z-index: 2;
  background: $stage-orange;
  transform: translateY(100%);
  transition: transform $duration $easing;
}

.stage.is-open .panel { transform: translateY(0); }

06Popover APIでJS0行メニュー

popover属性を付けるだけでメニューが開き、外側をクリックすると自動的に閉じます。実際に開閉を切り替えているのは:popover-openセレクタで座標を上書きする部分だけで、開閉のJavaScriptは1行も要りません。

popoveranchor자동 닫힘
// popover 属性が付いていない間(デモの初期値)は anchor の下に普通の箱として描く
.menu {
  position: absolute; top: calc(100% + 8px); left: 0; right: auto;
  margin: 0; border: 0;
  opacity: 0; transform: translateY(-8px) scale(.96); transform-origin: top left;
  transition: opacity $duration $easing, transform $duration $easing;
}

// 本物の Popover API で開くと top layer の上、同じ位置に座標だけ再計算する
.menu:popover-open {
  position: fixed; inset: auto;
  opacity: 1; transform: translateY(0) scale(1);
}

07リンクが順番に表示

メニューが開くと、リンクが30ms間隔で上から順に現れます。transition-delayにCSSカスタムプロパティ--iを掛け合わせているだけなので、リンクの数が増えてもJavaScript側の計算は増えません。

30ms역순위에서
$stagger: 30ms;            // リンクの間隔
$duration: 220ms;          // リンク1つが現れる時間

.menu__link {
  opacity: 0; transform: translateY(-6px);
  transition: opacity $duration $ease-spring, transform $duration $ease-spring;
  transition-delay: calc(var(--i) * #{$stagger});
}

.stage.is-open .menu__link { opacity: 1; transform: translateY(0); }

08アイコンから円形に広がる

ボタンの位置から円が広がって画面を覆い、メニューが現れます。動かしているのはclip-path circle()の半径だけで、widthやheightに触れないためレイアウトの再計算は起きません。

clip-path circle450ms오른쪽 위에서
$duration: 450ms;         // 円が画面を覆う時間

// アイコンの位置(右上)を起点に、clip-path の半径だけを動かす
.reveal {
  position: absolute; inset: 0; z-index: 2;
  background: $stage-ink;
  clip-path: circle(0% at calc(100% - 30px) 30px);
  transition: clip-path $duration $easing;
}

.stage.is-open .reveal { clip-path: circle(150% at calc(100% - 30px) 30px); }

09背景ぼかし+暗転

メニューが開くと背後の.pageがfilter: blur()とbrightness()で同時にぼやけて暗くなり、視線がメニューに集まります。半透明の重ね要素を別に用意せず、.page自体に直接filterをかけているのがこの実装の特徴です。

6px어둡게 40%10px
$blur: 6px;                // 背景がぼける強さ
$dim: .4;                  // 背景が暗くなる強さ(0〜1)

.page {
  transition: filter $dur-base $ease-spring;
}

.stage.is-open .page { filter: blur($blur) brightness(1 - $dim); }

どこで崩れるか — 落とし穴

9つを実装していて最初につまずいたのは05と08です。オーバーレイやreveal要素にz-index: 2を、ボタンにはz-index: 3を割り当てないと、メニューが開いた瞬間にボタンがパネルの下に隠れ、二度とクリックで閉じられなくなります。閉じる入り口を潰さないためのz-index管理は、背景を覆う系の項目に共通する落とし穴でした。

09は背後のページ全体にfilter: blur()をかけていますが、半透明パネルだけをぼかしたい場面ではbackdrop-filterのほうが向きます。backdrop-filterはChrome 76以降、Safariは18以降で接頭辞なしに対応し、Firefoxは103以降で使えます(MDNのbackdrop-filter)。もう一つ、06のようにJavaScriptを0行に保ちたいときは、popoverの代わりにチェックボックスと:has()で状態を拾う古い手も残っていますが、:has()はChrome 105以降、Safari 15.4以降、Firefox 121以降での対応です(MDNの:has())。対応が新しい分、素直に書けるのはpopoverのほうでした。9つとも直したファイルを開ける鍵は 2cpj9dyd という一続きの文字列で、下のボタンから zip を受け取ってください。

アクセシビリティ

9つとも prefers-reduced-motion: reduce では、グリッドとデモが繰り返すループ演出だけを止め、transitionを1msに縮めます。クリックによる本来の開閉自体は今までどおり働くので、動きを止めても機能は失われません。ボタンのaria-labelは開閉のたびに「メニューを開く」「メニューを閉じる」に書き換わり、06のpopoverだけはEscキーでの閉じ方やフォーカスの戻し方をブラウザが標準で面倒を見てくれます。

このほかのCSSアニメーションはCSSアニメーションの分類に、このサイトの成り立ちはこのサイトについてにまとめています。

FAQ

9つを1つのサイトにまとめて使っても大丈夫ですか?

構いません。それぞれtransform-originやz-indexの取り方が独立しているので、ページごとに違う項目を使い分けても衝突しません。ただし同じヘッダーの中で2種類の開閉ボタンを並べると、訪問者がどちらが本物のメニューか迷うので、1ページには1種類に絞るのがおすすめです。

Reactではクラスの付け外しの代わりに何を使いますか?

zipのreact/フォルダのコンポーネントはuseStateの真偽値ひとつでクラス名を切り替えるだけで、01〜05・07〜09はそれで完結します。06だけはpopovertarget属性の代わりにrefを持たせ、showPopover()とhidePopover()を呼ぶ形にしてあります。

スマホでも同じように開閉しますか?

はい、popoverもtransformもHTMLとCSSの標準機能なので、iOSのSafariでもAndroidのChromeでも同じように動きます。対応状況の詳細はMDNのPopover APIのページで確認できます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。