「ポートフォリオサイトに、ちょっと印象的なハンバーガーメニューを置きたい」「通常の上から降りるタイプのメニューに少し飽きてきた」——そんなときに試してほしいのが、画面右下の丸ボタンから円形に背景がふわっと広がり、そのあとリンクが順番にぽんぽんと出てくるリッチなメニューです。

この記事では、右下のFAB(Floating Action Button)から円形に展開する2段階アニメーション型のハンバーガーメニューを、ライブラリなしでCSSとJavaScriptだけで作る方法を解説します。

クリックすると、まず円形の背景がFABを起点に画面全体へ広がり、続いてナビリンクが少しずつ時間差で順番にスライドアップしてくる——そんな”波紋のような”動きが見どころです。

実際の動作はこちら

デモを見る(GitHub Pages)

ソースコード全文(GitHub)

コード全文はGitHubリポジトリで公開しているので、本記事では動作の核になる部分だけを抜粋して解説します。全部のコードを頭に入れる必要はありません。仕組みを理解して、安心してコピペできるようになることがゴールです。

この記事で分かること

  • 画面右下に常時浮かぶFAB(Floating Action Button)の作り方と、ポートフォリオに使うときの考え方
  • transform: scalecubic-bezier を使って、円形背景を画面全体へ広げるアニメーションの実装手順
  • :nth-childtransition-delay: calc() で実装する「スタッガードアニメーション」の仕組み
  • JavaScriptの setTimeout で2段階アニメーションのタイミングを制御する考え方
  • CSSの値とJSの定数を「同じ意味」に揃えて運用するためのルール
  • フォーカストラップ・スクロールロック・Escapeキー対応など、アクセシビリティへの配慮ポイント

このスニペットで作れるもの

このスニペットで作れるのは、画面右下に常時浮かぶ円形の「FABボタン」と、それをクリックすると円形に背景が広がってリンクが順次出現する2段階アニメーション型のメニューです。

動きの流れを文章で先取りすると、次のようになります。

  1. 画面右下に浮かぶ円形のFABボタン(ハンバーガー3本線)が常時表示されている
  2. FABをクリックすると、ボタンを起点にダークな円形背景が画面全体へ広がる
  3. 続いて、ナビゲーションリンクが少しずつ時間差で下からスライドアップしてフェードイン
  4. 閉じるときは逆順で、リンクがすっと消えてから背景が縮んで元の円に戻る

動作の特徴をまとめると次のとおりです。

  • 右下に固定配置されたFABスタイルのハンバーガーボタン(スクロールしてもついてくる)
  • FAB位置を起点にした円形展開アニメーション(cubic-bezier easing で自然な動き)
  • ナビリンクのスタッガードアニメーション(順次フェードイン+スライドアップ)
  • 閉じるときは逆順で収納(リンクフェードアウト → 背景縮小)
  • FABの3本線→×(バツ)変形アニメーション
  • Escapeキーでメニューを閉じられる
  • メニュー開放中はページスクロールが止まる(スクロールロック)
  • Tabキーのフォーカスが「FAB+展開ナビ内」でループする(フォーカストラップ)
  • aria属性によるアクセシビリティ対応
  • PC/SP両方で動作する設計(ポートフォリオ向け)

ポートフォリオサイトやクリエイティブ系サイトで、訪問者の印象に残しやすい少しリッチなメニューを置きたい——そんなときに使いやすい構成です。

実際の動きはGitHub Pagesのデモで確認できます。スマホサイズに近づけたり広げたりしながら触ってみると、このあとの解説が一気に分かりやすくなりますよ。

FAB(Floating Action Button)とは?

FAB(Floating Action Button)とは、画面の右下などに常時浮かぶ円形のボタンのことです。日本語にすると「浮いているアクションボタン」といったニュアンスで、Googleが提唱したMaterial Designというデザインルールから広まりました。

スマホアプリでよく見る「+」ボタンを思い浮かべると分かりやすいかもしれません。メールアプリの「新規作成」ボタンや、メモアプリの「追加」ボタンなど、「そのアプリで一番大事なアクション」を画面の片隅にいつも置いておく、という考え方のUIです。

FABの特徴は次の3つです。

  • 画面右下(または左下)に固定配置されている
  • 円形で、背景に少し影がついて”浮いて”見える
  • スクロールしても位置が動かない

Webサイトでも、問い合わせボタンや「上に戻る」ボタン、そして本記事のように「メニューを開くボタン」としてFABを応用するケースが増えています。

FABをハンバーガーメニューのトリガーとして使うと、ヘッダーをロゴだけのシンプル構成にできて、メニューの呼び出しはどこからでも片手で操作しやすい右下に置いておけます。スクロールしてページの下のほうを読んでいるときでも、わざわざヘッダーまで戻らずにメニューを開けるのも便利なポイントです。

クリエイティブ系サイトやポートフォリオでは「他と少し違う印象を残したい」というニーズが強いので、FAB+円形展開という組み合わせが武器になりやすい構成だと考えています。

HTMLの構造を見てみよう

このスニペットのHTML構造で特徴的なのは、「展開背景(c-expand-bg)」「展開ナビ(c-expand-nav)」「FABボタン(c-fab)」の3つを、ヘッダーの中ではなく <body> 直下に並べて置いている点です。

HTMLの要点だけ抜粋すると、次のような構造です。

<!-- ヘッダー(ロゴ+PCナビのみ。FABはここに置かない) -->
<header class="c-header">
  <div class="c-header__inner">
    <a href="#" class="c-header__logo">LOGO</a>
    <nav class="c-nav" aria-label="グローバルナビゲーション">
      <!-- PC用の横並びナビ(省略) -->
    </nav>
  </div>
</header>

<main class="l-container">
  <!-- メインコンテンツ -->
</main>

<!-- 展開型ナビゲーション背景(右下から円形に広がる) -->
<div class="c-expand-bg" aria-hidden="true"></div>

<!-- 展開型ナビゲーション(第2段階: リンク表示) -->
<nav
  class="c-expand-nav"
  id="c-expand-nav"
  aria-label="展開型ナビゲーション"
  aria-hidden="true"
>
  <ul class="c-expand-nav__list">
    <li class="c-expand-nav__item"><a href="#" class="c-expand-nav__link">TOP</a></li>
    <li class="c-expand-nav__item"><a href="#" class="c-expand-nav__link">ABOUT</a></li>
    <li class="c-expand-nav__item"><a href="#" class="c-expand-nav__link">WORKS</a></li>
    <li class="c-expand-nav__item"><a href="#" class="c-expand-nav__link">BLOG</a></li>
    <li class="c-expand-nav__item"><a href="#" class="c-expand-nav__link">CONTACT</a></li>
  </ul>
</nav>

<!-- FABハンバーガーボタン(右下固定) -->
<button
  class="c-fab"
  type="button"
  aria-label="メニューを開く"
  aria-expanded="false"
  aria-controls="c-expand-nav"
>
  <span class="c-fab__line"></span>
  <span class="c-fab__line"></span>
  <span class="c-fab__line"></span>
</button>

クラス名のプレフィックスには、FLOCSS(フロックス)というCSS設計ルールを採用しています。c- はComponent(再利用できる部品)を意味します。展開背景・展開ナビ・FABの3つは、それぞれ独立した部品として設計されています。FLOCSSの基礎については別記事のFLOCSSとは?CSS設計ルールをわかりやすく解説で詳しく解説しています。

ハンバーガーボタンには <div> ではなく <button> タグを使います。キーボード操作やスクリーンリーダーへの対応が自動的に行われるため、アクセシビリティ上のベストプラクティスです。

FAB・展開背景・展開ナビをヘッダーの外に置く理由

3つの要素をすべて <header> の外、<body> 直下に置いているのには理由があります。

ひとつ目は、ヘッダーが position: sticky で動いているため、その内側に position: fixed の要素を置くと意図通りに動かないケースがあることです。sticky のコンテキストの中に fixed を入れると、親要素のスタイル次第で位置がズレたり、画面全体を覆いきれなくなったりすることがあります。

ふたつ目は、展開背景が画面全体を覆う必要があることです。overflow: hidden のかかった親要素の内側にいると、その親の幅・高さの内側でしかはみ出せません。<body> 直下に置けば、純粋にビューポート全体を基準に動かせます。

みっつ目は、FABを「常に画面の最前面」に置きたいことです。これも <body> 直下に置いて z-index を素直に管理したほうが、後から見直したときに迷いにくくなります。

z-indexは「ヘッダー 20 → 展開背景 30 → 展開ナビ 40 → FAB 50」の4層で重ねていて、FABが常に一番手前にいる構造です。詳しくは次のCSSセクションで説明します。

展開背景と展開ナビを別要素に分ける理由

「背景」と「ナビ」を1つの <div> にまとめずに、わざわざ別要素に分けているのもポイントです。

なぜかというと、それぞれに全く違う動きのアニメーションをかけたいからです。

  • 展開背景(c-expand-bg: FABを起点に transform: scale で円形に広がる
  • 展開ナビ(c-expand-nav: 画面全体に position: fixed; inset: 0 で配置し、opacity でフェードイン

ひとつの要素で両方の動きを兼ねようとすると、transform-origin の指定や、子要素のリンクが背景と一緒に拡大されてしまう問題などで、CSSがどんどん複雑になっていきます。

「目的別に要素を分けると、CSSはむしろシンプルになる」——これは設計の基本のひとつだと感じています。要素が1つ増えるとHTMLは少し冗長になりますが、その分CSSの見通しが良くなって、後から触る人(数か月後の自分も含めて)が迷いにくくなります。

FABボタンのaria属性

aria属性は、スクリーンリーダーなどの支援技術に対して「この要素が今どんな状態か」を伝えるための属性です。このスニペットでは以下のaria属性を使っています。

属性役割
aria-label="メニューを開く"ボタンの名前をスクリーンリーダーに伝える(ボタン内にテキストがないため)
aria-expanded="false"メニューが閉じていることを示す(開いたとき "true" に変わる)
aria-controls="c-expand-nav"このボタンが操作する要素のIDを示す(id="c-expand-nav" と対応)
aria-hidden="true"(展開ナビ側)非表示のとき「この要素は読まなくていい」と伝える
aria-hidden="true"(展開背景側)装飾用の要素なので、JSからは触らずに常に true のまま

展開背景の aria-hidden="true" は、JavaScriptからの操作対象外です。あくまで「ダークな円が広がる装飾」なので、スクリーンリーダーには読ませない、という考え方で固定値にしています。

最初は難しく感じるかもしれませんが、このスニペットをコピペするだけでアクセシビリティ対応ができます。慣れてきたら「どうしてこう書くのか」を一つずつ確認してみてください。

CSSで円形展開とスタッガードアニメーションを実装する

CSS全文は長いため、GitHubリポジトリに譲り、ここでは動きの核になるポイントだけを抜粋して解説します。

このスニペットのCSSで押さえておきたいのは、次の4点です。

  1. z-indexの4層設計(カスタムプロパティで一元管理)
  2. FABを右下に固定する方法
  3. 円形背景が画面全体に広がる第1段階アニメーション
  4. ナビリンクが順次出現する第2段階のスタッガードアニメーション

順番に見ていきましょう。

z-indexの4層設計(カスタムプロパティで一元管理)

このスニペットでは、4つの要素をz-indexの階層で重ねています。

:root {
  /* ===== z-index 階層管理 ===== */
  --z-header:     20; /* ヘッダー背景 */
  --z-overlay:    30; /* 展開背景(オーバーレイとして機能) */
  --z-drawer:     40; /* 展開ナビゲーション */
  --z-floating:   50; /* FABボタン(常に最前面) */
}
要素z-index役割
c-header--z-header: 20ヘッダー(一番下)
c-expand-bg--z-overlay: 30展開背景(円形に広がるダークな背景)
c-expand-nav--z-drawer: 40展開ナビゲーション(リンクを表示する層)
c-fab--z-floating: 50FABボタン(常に最前面)

FABが常に最前面(--z-floating: 50)にいる理由は、閉じるときもFABを操作できるようにするためです。展開ナビが画面全体を覆っている状態でも、FABがその上に浮いていれば、ユーザーは迷わず同じ場所をクリックしてメニューを閉じられます。

z-indexは数値を大きくすれば上に来るシンプルな仕組みですが、設計なしに増やしていくと「100、500、9999……」と数字がインフレして、どれが上か下か分からなくなりがちです。カスタムプロパティで --z-header --z-overlay のように名前を付けてあげると、役割が一目で分かるようになります。

FABボタンを右下に固定する(position: fixed

FABを画面右下に固定するのは、position: fixedbottom / right の組み合わせです。

.c-fab {
  /* ボタンリセット */
  appearance: none;
  border: none;
  cursor: pointer;

  /* 右下に固定配置 */
  position: fixed;
  bottom: var(--fab-offset);
  right: var(--fab-offset);
  z-index: var(--z-floating);

  /* 円形FABスタイル */
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 5px;
  width: var(--fab-size);
  height: var(--fab-size);
  border-radius: 50%;
  background-color: var(--fab-bg);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.24);
  padding: 0;

  transition:
    background-color 0.3s ease,
    box-shadow 0.3s ease,
    transform 0.3s ease;
}

ポイントを順に見ていきましょう。

  • position: fixed で、スクロールしても位置が動かないように固定
  • bottom: var(--fab-offset) / right: var(--fab-offset) で、ビューポートの右下から --fab-offset(24px)の余白を取る
  • width / height--fab-size(56px) にして正方形を作り、border-radius: 50% で完全な円形に
  • box-shadow: 0 4px 16px rgba(0, 0, 0, 0.24) で、ボタンが少し浮いているような影をつける

box-shadow の数値は感覚値ですが、Material Designの「elevation 6」程度を目安にするとちょうど良い”浮き感”になります。あまり強くしすぎると重たい印象になり、弱すぎるとフラットに見えてしまうので、自分の好みで微調整してみてください。

ホバー時には少し拡大する演出を入れています。

@media (any-hover: hover) and (pointer: fine) {
  .c-fab:hover {
    box-shadow: 0 6px 24px rgba(0, 0, 0, 0.32);
    transform: scale(1.05);
  }
}

@media (any-hover: hover) and (pointer: fine) というメディアクエリは、「マウスなどでホバー操作ができる、かつ正確なポインターを持つデバイス」のときだけホバースタイルを適用する書き方です。

これを書かずに :hover だけを使うと、スマホのタッチ操作でもホバースタイルが「タップした瞬間に発動して残り続ける」という挙動になり、見た目がチカチカしてしまうことがあります。タッチデバイスを除外しておくと、PCではホバーが効き、スマホではタップしたときに余計なエフェクトが残らなくなります。

FABの3本線→×(バツ)変形アニメーション

FABが押されたときに、3本線が×(バツ)に変わるアニメーションは、transform を使って3本それぞれを動かす方法で実装しています。

/* 1本目: 右上がりの斜線 */
.c-fab.is-active .c-fab__line:nth-child(1) {
  /* 線の高さ(2px) + gap(5px) = 7px(中央線との距離) */
  transform: translateY(7px) rotate(45deg);
}

/* 2本目: 非表示 */
.c-fab.is-active .c-fab__line:nth-child(2) {
  opacity: 0;
}

/* 3本目: 右下がりの斜線 */
.c-fab.is-active .c-fab__line:nth-child(3) {
  /* 線の高さ(2px) + gap(5px) = 7px(中央線との距離) */
  transform: translateY(-7px) rotate(-45deg);
}

translateY(7px) の数値には根拠があります。線の高さ(height: 2px)と、線間のギャップ(gap: 5px)を足すと 2 + 5 = 7px になります。この距離だけ上下に動かすことで、1本目と3本目が中央の線の位置で交差して×形になります。

真ん中の線は opacity: 0 で消すだけ。1本目と3本目だけ回転+移動させるシンプルなロジックです。

円形背景の展開アニメーション(第1段階)

ここからが本記事の核です。FABの位置を起点に円形背景が画面全体へ広がっていく第1段階のアニメーションを作っていきます。

.c-expand-bg {
  position: fixed;
  z-index: var(--z-overlay);
  pointer-events: none; /* 背景自体はクリック不可 */

  /* FABボタンの中心を起点に配置 */
  bottom: calc(var(--fab-offset) + var(--fab-size) / 2);
  right: calc(var(--fab-offset) + var(--fab-size) / 2);

  /* 初期状態: サイズ0 */
  width: 0;
  height: 0;
  border-radius: 50%;
  background-color: var(--expand-bg);

  /* transformで拡大。中心を右下に固定するためtranslateで調整 */
  transform: translate(50%, 50%) scale(0);
  transition:
    transform var(--expand-transition-bg),
    width 0s,
    height 0s;

  opacity: 0;
}

/* 第1段階: 円形背景が画面全体に広がる */
.c-expand-bg.is-expanding {
  /* 画面の対角線の2倍以上の大きさにして画面全体をカバー */
  width: 300vmax;
  height: 300vmax;
  transform: translate(50%, 50%) scale(1);
  opacity: 1;
}

ここでポイントになるのが3つあります。

1つ目: bottom / rightcalc() で指定して、円の中心をFABボタンの中心に合わせる

bottom: calc(var(--fab-offset) + var(--fab-size) / 2) という計算で、FABの中心位置を基準にしています。--fab-offset(24px)はFABの右下からの余白で、--fab-size / 2(28px)はFAB半径分です。両者を足すことで、円の中心がちょうどFABボタンの中心と重なります。

transform: translate(50%, 50%) を組み合わせることで、width / height がいくつになっても円の中心位置を固定したまま拡大できます。

2つ目: 300vmax という巨大なサイズで「画面全体を確実に覆う」

展開時のサイズは width: 300vmax; height: 300vmax; です。vmax というのは「ビューポートの大きい方の辺を100とした単位」で、たとえば横長の画面なら横幅基準、縦長の画面なら縦幅基準でサイズが決まります。

100vmax だと画面の長辺と同じ大きさですが、画面の対角線(√2倍)よりは小さいので、四隅が円から少しはみ出してしまうことがあります。300vmax という余裕を持った数値にしておけば、どんな画面サイズでも確実に画面全体を覆えます。

「ぴったり計算するより、余裕を持って大きくしておく」のはCSSアニメーションでよく使うテクニックです。

3つ目: cubic-bezier(0.4, 0, 0.2, 1) で「最初は速く、終盤はゆっくり」の自然な動き

トランジションには次の値を使っています。

:root {
  --expand-transition-bg: 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}

cubic-bezier() は、アニメーションの加速・減速のカーブを自由にカスタマイズするための関数です。引数の4つの数値で、動きのカーブを制御できます。

cubic-bezier(0.4, 0, 0.2, 1) という値は、Material Designで「standard easing」と呼ばれるイージングです。動きの特徴は次のようなイメージです。

  • 序盤: スッと素早く動き出す
  • 中盤: そのまま勢いよく進む
  • 終盤: スーッとゆっくり止まる

普段よく使う easecubic-bezier(0.25, 0.1, 0.25, 1))より少しキビキビしていて、リッチで現代的な印象の動きになります。「自然なのに少しだけ”狙った感じ”がある」のがこのイージングの良さで、Material Designを採用しているGoogle系のサービスで頻繁に見かける動きです。

数値の意味を頭で完全に理解する必要はなく、cubic-bezier.com というツールにこの数値を入れると、カーブのグラフが視覚的に確認できます。値を少し変えてみて、自分の好みの動きを探してみると感覚が掴みやすくなります。

閉じるときはこの逆のアニメーションを is-closing クラスで定義しています(後述)。

ナビリンクのスタッガードアニメーション(第2段階)

円形背景が広がり終わったら、続けてナビリンクが順番に表示されます。これがいわゆる「スタッガード(staggered)アニメーション」です。

スタッガードアニメーションとは、複数の要素が、少しずつタイミングをずらしながら順番にアニメーションする演出のことです。「パパパッ」と連続して出てくる動きが特徴で、メニューに高級感やリッチな印象を加えてくれます。

/* 各リンクの初期状態(非表示・下にずれた位置) */
.c-expand-nav__item {
  opacity: 0;
  transform: translateY(30px);
  transition:
    opacity var(--expand-transition-nav),
    transform var(--expand-transition-nav);
  /* 閉じるときは遅延なしで即座にフェードアウト */
  transition-delay: 0s;
}

/* is-open 時: 表示(元の位置に戻る) */
.c-expand-nav.is-open .c-expand-nav__item {
  opacity: 1;
  transform: translateY(0);
}

/* 各アイテムに遅延を付けてスタッガードアニメーションにする */
.c-expand-nav.is-open .c-expand-nav__item:nth-child(1) {
  transition-delay: calc(var(--expand-item-delay) * 1);
}

.c-expand-nav.is-open .c-expand-nav__item:nth-child(2) {
  transition-delay: calc(var(--expand-item-delay) * 2);
}

.c-expand-nav.is-open .c-expand-nav__item:nth-child(3) {
  transition-delay: calc(var(--expand-item-delay) * 3);
}

.c-expand-nav.is-open .c-expand-nav__item:nth-child(4) {
  transition-delay: calc(var(--expand-item-delay) * 4);
}

.c-expand-nav.is-open .c-expand-nav__item:nth-child(5) {
  transition-delay: calc(var(--expand-item-delay) * 5);
}

仕組みを分解すると、次の3ステップです。

  1. すべてのリンクの初期状態を opacity: 0translateY(30px)(透明&30px下にずらす)にしておく
  2. .c-expand-nav.is-open 時に opacity: 1; transform: translateY(0) で「表示+元の位置に戻す」
  3. :nth-child(n) セレクタで n 番目のリンクに transition-delay: calc(var(--expand-item-delay) * n) を割り当てる

--expand-item-delay: 0.08s がスタッガードアニメーションの肝です。1番目のリンクは0.08秒後、2番目は0.16秒後、3番目は0.24秒後……と、80msずつタイミングがずれていくので、リンクが「パパパッ」と順番に出てくるように見えます。

数値を 0.05s にすると素早くキレ良く、0.12s にするとゆったり落ち着いた印象に変わります。サイトのトーンに合わせて調整してみてください。

そして、閉じるときに注目してほしいのが transition-delay: 0s(初期状態側に書いた値)が再度適用される点です。

CSSの詳細度の関係で、.is-open クラスが外れると .c-expand-nav__item の素の状態に戻り、transition-delay: 0s が有効になります。結果として、開くときは順番に、閉じるときは全部一斉に消える動きになります。閉じる動作は素早いほうが気持ちよく感じられるので、この使い分けは意図的です。

閉じるアニメーションの仕組み(is-closing クラス)

閉じるときは is-expanding クラスを外して、代わりに is-closing クラスを付けます。

/* 閉じるとき: 逆順で縮小 */
.c-expand-bg.is-closing {
  width: 300vmax;
  height: 300vmax;
  transform: translate(50%, 50%) scale(0);
  opacity: 1;
  transition:
    transform var(--expand-transition-bg),
    opacity 0.1s ease 0.4s;
}

ポイントは transition 指定のうち、opacity の行に注目してください。

/* .c-expand-bg.is-closing の transition 指定のうち、opacity の部分に注目 */
transition:
  transform var(--expand-transition-bg),
  opacity 0.1s ease 0.4s; /* ← ここ:opacity を 0.1s かけて、0.4s 待ってから開始 */

これは「opacity を0.1秒かけてイージングし、0.4秒”待ってから”開始する」という意味です。transition の3つ目の値が遅延(transition-delay)にあたります。

なぜ遅らせるかというと、「先に背景がscaleで縮んで → 最後にふっとopacityが消える」という順番にしたいからです。

もし opacity の遅延を入れずに transform と同時に消してしまうと、まだ縮小途中の円が透明になりかけて、画面の真ん中あたりで「フェードアウトしながら縮んでいく」やや雑な印象の動きになります。

transform を先に動かして円を小さくし、最後に残った小さな円を opacity でふっと消す——この順序にすると、波紋がきれいに収束する感じの動きが作れます。

こういう「閉じるときの細かい順序」を整えると、メニュー全体の印象がぐっと洗練されます。地味ですが、リッチな動きを作るうえで効きやすい工夫だと感じています。

JavaScriptの実装を見てみよう

JavaScript全文はGitHubリポジトリに譲り、ここでは核となる処理に絞って解説します。

このスニペットのJSで一番ユニークなのが、2段階アニメーションのタイミング制御です。CSSの transition-delay だけでは制御しきれないので、JS側の setTimeout で順序を整えています。

2段階アニメーションのタイミング制御(setTimeout + 定数管理)

JS側では、アニメーションのタイミングをすべて定数で管理しています。

// 2段階展開のタイミング定数
// CSS カスタムプロパティ --expand-transition-bg(0.5s)に合わせる
// CSS 側の値を変更した場合はここも合わせて変更する
const PHASE1_DURATION = 400; // 第1段階(背景展開)の待機時間
const PHASE2_FOCUS_DELAY = 500; // 第2段階完了後のフォーカス移動タイミング

// 閉じるときのタイミング定数
// CSS カスタムプロパティ --expand-transition-nav(0.4s)に合わせる
const CLOSE_NAV_DURATION = 300; // ナビフェードアウト待機時間

// 閉じるときの背景縮小アニメーション待機時間
// CSS カスタムプロパティ --expand-transition-bg: 0.5s に対応
const CLOSE_BG_DURATION = 500;

それぞれの定数の意味を整理すると、次のとおりです。

定数役割対応するCSS変数
PHASE1_DURATION400背景展開完了を待ってから第2段階開始(CSS500msより少し早めに切り替えてテンポを出す)--expand-transition-bg: 0.5s
PHASE2_FOCUS_DELAY500リンク表示後にフォーカスを移すまでの待機--expand-transition-bg: 0.5s
CLOSE_NAV_DURATION300閉じるときのナビフェードアウト待機(CSS400msより少し早めに次段階へ)--expand-transition-nav: 0.4s
CLOSE_BG_DURATION500閉じるときの背景縮小待機--expand-transition-bg: 0.5s

ここで一番大事なのが、「CSS側の値を変えたら、JS側の定数も必ず合わせる」という運用ルールです。

たとえば「もう少しゆっくり広がるアニメーションにしたい」と思って CSS の --expand-transition-bg0.5s から 0.8s に変えたとします。このとき、JS側の PHASE1_DURATIONCLOSE_BG_DURATION をそのままにしておくと、「背景がまだ動いている途中なのにJSが次の処理を始めてしまう」という不整合が起きます。結果として、開閉のタイミングがズレてチカチカした見た目になってしまいます。

CSSとJSをまたぐ値は、「両方を同時に直す」必要がある——というルールを、コメントとして該当箇所に明示しておくのがおすすめです。このスニペットでも、定数の上に // CSS カスタムプロパティ --expand-transition-bg(0.5s)に合わせる と書いてあります。

より堅牢にしたい場合のヒント

CSS変数の値をJS側から getComputedStyle() で読み取って、parseFloat でミリ秒に変換する方法もあります。そうすればCSS側の数値を変えるだけで両方が連動しますが、その分コードは少し長くなります。シンプルさとどちらを優先するかは、プロジェクト次第で選んでみてください。

openMenu / closeMenu の処理の流れ

開閉処理は openMenu / closeMenu という2つの関数に分かれていて、お互いに鏡写しの構造になっています。

/**
 * メニューを開く(2段階展開)
 */
function openMenu() {
  if (isAnimating) return;
  isAnimating = true;

  // FABボタンの×変形
  fab.classList.add("is-active");

  // aria 属性更新
  fab.setAttribute("aria-expanded", "true");
  fab.setAttribute("aria-label", "メニューを閉じる");

  // スクロールロック
  document.body.style.overflow = "hidden";

  // === 第1段階: 円形背景アニメーション ===
  expandBg.classList.remove("is-closing");
  expandBg.classList.add("is-expanding");

  // === 第2段階: ナビリンクのフェードイン(第1段階の途中から開始) ===
  setTimeout(function () {
    expandNav.classList.add("is-open");
    expandNav.setAttribute("aria-hidden", "false");

    // ナビ内の最初のフォーカス可能要素にフォーカス移動
    const firstFocusable = expandNav.querySelectorAll(FOCUSABLE_SELECTOR)[0];
    if (firstFocusable) {
      setTimeout(function () {
        firstFocusable.focus();
      }, PHASE2_FOCUS_DELAY);
    }

    isAnimating = false;
  }, PHASE1_DURATION);
}

開く流れを追うと次のようになります。

  1. FABに is-active を付ける(×形に変形開始)
  2. aria属性を更新する(aria-expanded: true、ラベルを「閉じる」に)
  3. スクロールロック(document.body.style.overflow = "hidden"
  4. 展開背景に is-expanding を付けて円形展開を開始
  5. PHASE1_DURATION(400ms)待機してから、展開ナビに is-open を付けてリンク表示開始
  6. さらに PHASE2_FOCUS_DELAY(500ms)待ってから、最初のリンクにフォーカスを移動

PHASE1_DURATION を400msにしていて、CSSの --expand-transition-bg: 0.5s(500ms)より少しだけ短くしている点に注目してください。完全にCSS側の終了を待つと「背景が広がりきった後にリンクが出現する」という”待たされる”感じになります。少しだけ早めに次のフェーズを始めると、動きが自然につながって、テンポよく見えます。

閉じる側はその逆順です。

/**
 * メニューを閉じる(逆順で収納)
 */
function closeMenu() {
  if (isAnimating) return;
  isAnimating = true;

  // === 逆順 第1段階: ナビリンクのフェードアウト ===
  expandNav.classList.remove("is-open");
  expandNav.setAttribute("aria-hidden", "true");

  // === 逆順 第2段階: 円形背景の縮小(ナビ消失後に開始) ===
  setTimeout(function () {
    expandBg.classList.remove("is-expanding");
    expandBg.classList.add("is-closing");

    // FABボタンの3本線復帰
    fab.classList.remove("is-active");

    // aria 属性更新
    fab.setAttribute("aria-expanded", "false");
    fab.setAttribute("aria-label", "メニューを開く");

    // スクロールロック解除
    document.body.style.overflow = "";

    // 閉じアニメーション完了後にクラスをクリーンアップ
    setTimeout(function () {
      expandBg.classList.remove("is-closing");
      expandNav.style.visibility = "";
      isAnimating = false;
    }, CLOSE_BG_DURATION);
  }, CLOSE_NAV_DURATION);
}

閉じる流れを追うと次のようになります。

  1. 展開ナビから is-open を外す(リンクが一斉にフェードアウト開始)
  2. CLOSE_NAV_DURATION(300ms)待機
  3. 展開背景に is-closing を付けて円形縮小を開始、FABの×を3本線に戻す、aria更新、スクロールロック解除
  4. さらに CLOSE_BG_DURATION(500ms)待機してから、is-closing クラスを取り除いてクリーンアップ

開くときと閉じるときで処理順がきれいに対称になっているのが、このメニューの設計上のポイントです。「リンク → 背景」「背景 → リンク」と順序を反転させるだけで、自然な開閉演出が作れます。

isAnimating フラグで多重起動を防ぐ

openMenu / closeMenu の冒頭にある次の1行は、地味ですがとても大事な処理です。

if (isAnimating) return;
isAnimating = true;

これは、「アニメーションが動いている最中はクリックを無視する」ためのフラグ管理です。

ユーザーがFABを連打した場合や、勢いよく開閉を繰り返した場合、setTimeout の処理が重なると「広がる途中で閉じる処理が走る」「閉じている途中で開く処理が走る」といった状態になり、見た目が崩れることがあります。

isAnimating = true の間は早期 return することで、アニメーション中の追加クリックをすべて無視できます。アニメーションが完了したタイミングで isAnimating = false に戻して、次の操作を受け付けるようにしています。

実案件でも、トランジションを使ったUIではこのフラグ管理が役立ちます。「連打されてもUIが崩れない」堅牢さは、リッチな動きを使うときほど大事になります。

スクロールロックの仕組み

メニューが開いている間は、背景のページスクロールを止めています。

// メニューを開くとき
document.body.style.overflow = "hidden";

// メニューを閉じるとき
document.body.style.overflow = "";

overflow: "hidden" でスクロールを止め、""(空文字)の代入でインラインスタイルを除去してデフォルトの挙動に戻しています。

展開背景が画面全体を覆っているとはいえ、裏のページがスクロールしてしまうと「メニューを開いているのに背景の文字が動く」という違和感のある挙動になります。スクロールロックを入れておくと、開いている間はメニューにだけ集中できる状態になります。

iOSでの注意点

iOSのSafariでは overflow: hidden だけではスクロールが止まらないケースがあります。実案件で対応する場合は、position: fixed + スクロール位置の保存・復元を組み合わせる方法が安定します。このスニペットではシンプルさを優先して overflow のみの実装としています。

フォーカストラップ — FABボタンを含めてループ

フォーカストラップは、Tabキーのフォーカスをメニュー内に閉じ込める仕組みです。メニューが開いているのに、Tabキーでメニューの外に出ていってしまうと、キーボード操作のユーザーが迷子になります。

このスニペットで特徴的なのは、FABボタンをフォーカストラップの範囲に含めている点です。

function trapFocus(e) {
  if (e.key !== "Tab") return;
  if (!expandNav.classList.contains("is-open")) return;

  // FABボタン + 展開ナビ内のフォーカス可能要素を結合
  const navFocusable = Array.from(expandNav.querySelectorAll(FOCUSABLE_SELECTOR));
  const focusableElements = [fab].concat(navFocusable);
  if (focusableElements.length === 0) return;

  const firstElement = focusableElements[0];
  const lastElement = focusableElements[focusableElements.length - 1];

  if (e.shiftKey) {
    if (document.activeElement === firstElement) {
      e.preventDefault();
      lastElement.focus();
    }
  } else {
    if (document.activeElement === lastElement) {
      e.preventDefault();
      firstElement.focus();
    }
  }
}

const focusableElements = [fab].concat(navFocusable) の部分で、配列の先頭にFABボタンを追加しています。これによりフォーカスは「FAB → リンク1 → リンク2 → … → 最後のリンク → FAB → リンク1 …」とループします。

このスニペットには「閉じるボタン」が独立して存在しません。FAB自体が「開く・閉じる」を兼ねたトグルになっています。だからこそ、フォーカスがFABに戻ってくる経路を確保しておかないと、キーボードユーザーがメニューを閉じる手段を失ってしまいます。

「閉じるボタンがない」設計のメニューを作るときは、フォーカストラップにトリガーボタンを必ず含める——これは覚えておきたいパターンのひとつです。

Escapeキー・リサイズ対応

Escapeキーでメニューを閉じる処理と、リサイズ時の処理は1つのリスナーにまとめています。

// キーボード操作(Escape / Tab)を1つのリスナーで統合管理
document.addEventListener("keydown", function (e) {
  // Escape: メニューを閉じてフォーカスを戻す
  if (e.key === "Escape" && expandNav.classList.contains("is-open")) {
    closeMenu();
    // 閉じアニメーション完了後にFABボタンにフォーカスを戻す
    setTimeout(function () {
      fab.focus();
    }, CLOSE_NAV_DURATION + CLOSE_BG_DURATION);
    return;
  }
  // Tab: フォーカストラップ
  if (e.key === "Tab") {
    trapFocus(e);
  }
});

注目してほしいのが、setTimeout の遅延に CLOSE_NAV_DURATION + CLOSE_BG_DURATION(300 + 500 = 800ms)を渡している点です。

閉じるアニメーションは「ナビフェードアウト → 背景縮小」の2段階で進むため、両方が終わってから fab.focus() を呼ばないと、フォーカスのリングがアニメーション途中で動いてしまい、見た目がややせわしなくなります。「JS側の setTimeout の合計時間 = CSSのアニメーション完了時間」になっているか、を意識すると良いポイントです。

リサイズ対応は matchMedia を使っています。

// 画面幅が768px以上になったらメニューを閉じる(縦横回転・リサイズ対応)
const mediaQuery = window.matchMedia("(min-width: 768px)");
mediaQuery.addEventListener("change", function (e) {
  if (e.matches) {
    if (expandNav.classList.contains("is-open")) {
      // アニメーションをスキップして即座に閉じる
      isAnimating = false;
      fab.classList.remove("is-active");
      expandBg.classList.remove("is-expanding", "is-closing");
      expandNav.classList.remove("is-open");
      // ...(aria属性更新・スクロールロック解除)
    }
    expandNav.removeAttribute("aria-hidden");
  } else {
    expandNav.setAttribute("aria-hidden", "true");
  }
});

768px以上に画面幅が広がった瞬間、メニューを開いていればアニメーションをスキップして即座にリセットします。リサイズ中にアニメーションが走るとガクついて見えるので、こちらは「即終了」させたほうが安心です。

このスニペットはポートフォリオ向けにPC/SP両方で動作する設計になっていますが、「PCではFABを表示せずに通常のグローバルナビだけにしたい」という場合に備えて、リサイズ対応のロジックは残してあります。CSS側でメディアクエリのコメントアウトを外せば、PCでFABを非表示にできる仕組みです。

コピペで使えるソースコード全文

HTML・CSS・JSの全文は、GitHubリポジトリで公開しています。そのままコピペして使える状態で置いていますので、手元のプロジェクトに取り込んでお使いください。

コピペ用:HTML / CSS / JS 全文

ここからコピペで使える完全なソースコードを掲載しています。GitHubを使わない方はこちらをそのままコピーしてください。HTML・CSS・JSの3ファイル構成で、そのまま動作します。

HTML(index.html)

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>007_hamburger-expand-corner</title>
    <link rel="stylesheet" href="./assets/css/style.css" />
  </head>
  <body>
    <header class="c-header">
      <div class="c-header__inner">
        <!-- ロゴ -->
        <a href="#" class="c-header__logo">LOGO</a>

        <!-- ナビゲーション(PC: 横並び表示) -->
        <nav class="c-nav" aria-label="グローバルナビゲーション">
          <ul class="c-nav__list">
            <li class="c-nav__item"><a href="#" class="c-nav__link">TOP</a></li>
            <li class="c-nav__item"><a href="#" class="c-nav__link">ABOUT</a></li>
            <li class="c-nav__item"><a href="#" class="c-nav__link">WORKS</a></li>
            <li class="c-nav__item"><a href="#" class="c-nav__link">BLOG</a></li>
            <li class="c-nav__item"><a href="#" class="c-nav__link">CONTACT</a></li>
          </ul>
        </nav>
      </div>
    </header>

    <main class="l-container">
      <!-- メインコンテンツをここに -->
    </main>

    <!-- 展開型ナビゲーション背景(右下から円形に広がる) -->
    <div class="c-expand-bg" aria-hidden="true"></div>

    <!-- 展開型ナビゲーション(第2段階: リンク表示) -->
    <nav
      class="c-expand-nav"
      id="c-expand-nav"
      aria-label="展開型ナビゲーション"
      aria-hidden="true"
    >
      <ul class="c-expand-nav__list">
        <li class="c-expand-nav__item"><a href="#" class="c-expand-nav__link">TOP</a></li>
        <li class="c-expand-nav__item"><a href="#" class="c-expand-nav__link">ABOUT</a></li>
        <li class="c-expand-nav__item"><a href="#" class="c-expand-nav__link">WORKS</a></li>
        <li class="c-expand-nav__item"><a href="#" class="c-expand-nav__link">BLOG</a></li>
        <li class="c-expand-nav__item"><a href="#" class="c-expand-nav__link">CONTACT</a></li>
      </ul>
    </nav>

    <!-- FABハンバーガーボタン(右下固定) -->
    <button
      class="c-fab"
      type="button"
      aria-label="メニューを開く"
      aria-expanded="false"
      aria-controls="c-expand-nav"
    >
      <span class="c-fab__line"></span>
      <span class="c-fab__line"></span>
      <span class="c-fab__line"></span>
    </button>

    <script src="./assets/js/script.js" defer></script>
  </body>
</html>

CSS(assets/css/style.css)

/* ================================================
   カスタムプロパティ
   ================================================ */
:root {
  --header-height-sp: 60px;
  --header-height-pc: 72px;

  /* FABボタン */
  --fab-size: 56px;
  --fab-offset: 24px; /* 右下からのオフセット */
  --fab-bg: #1a1a1a;
  --fab-bg-active: #1a1a1a;

  /* 展開背景 */
  --expand-bg: #1a1a1a;
  --expand-transition-bg: 0.5s cubic-bezier(0.4, 0, 0.2, 1);
  --expand-transition-nav: 0.4s ease;
  --expand-item-delay: 0.08s;

  /* ===== z-index 階層管理 ===== */
  --z-header:     20; /* ヘッダー背景 */
  --z-overlay:    30; /* 展開背景(オーバーレイとして機能) */
  --z-drawer:     40; /* 展開ナビゲーション */
  --z-floating:   50; /* FABボタン(常に最前面) */
}

/* ================================================
   c-header: ヘッダー全体
   ================================================ */
.c-header {
  position: sticky;
  top: 0;
  z-index: var(--z-header);
  background-color: #fff;
  border-bottom: 1px solid #e0e0e0;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
}

.c-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  max-width: calc(1200px + 20px * 2);
  margin-inline: auto;
  padding-inline: 20px;
  height: var(--header-height-sp);
}

@media (min-width: 768px) {
  .c-header__inner {
    padding-inline: 40px;
    height: var(--header-height-pc);
  }
}

.c-header__logo {
  font-size: 20px;
  font-weight: bold;
  color: #333;
  text-decoration: none;
  letter-spacing: 0.05em;
}

@media (min-width: 768px) {
  .c-header__logo {
    font-size: 24px;
  }
}

/* ================================================
   c-nav: PCナビゲーション(横並び)
   ================================================ */
.c-nav {
  /* モバイル: 非表示 */
  display: none;
}

@media (min-width: 768px) {
  /* PC: 常に表示(横並び) */
  .c-nav {
    display: block;
  }
}

.c-nav__list {
  list-style: none;
  margin: 0;
  padding: 0;
}

@media (min-width: 768px) {
  .c-nav__list {
    display: flex;
    gap: 32px;
  }
}

.c-nav__link {
  display: block;
  padding: 0;
  color: #333;
  text-decoration: none;
  font-size: 14px;
  font-weight: 500;
  letter-spacing: 0.05em;
  transition: color 0.2s ease;
}

@media (any-hover: hover) and (pointer: fine) {
  .c-nav__link:hover {
    color: #0066cc;
  }
}

/* ================================================
   c-fab: FABスタイル ハンバーガーボタン(右下固定)
   ================================================ */
.c-fab {
  /* ボタンリセット */
  appearance: none;
  border: none;
  cursor: pointer;

  /* 右下に固定配置 */
  position: fixed;
  bottom: var(--fab-offset);
  right: var(--fab-offset);
  z-index: var(--z-floating); /* FABは常に最前面 */

  /* 円形FABスタイル */
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 5px;
  width: var(--fab-size);
  height: var(--fab-size);
  border-radius: 50%;
  background-color: var(--fab-bg);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.24);
  padding: 0;

  transition:
    background-color 0.3s ease,
    box-shadow 0.3s ease,
    transform 0.3s ease;
}

@media (any-hover: hover) and (pointer: fine) {
  .c-fab:hover {
    box-shadow: 0 6px 24px rgba(0, 0, 0, 0.32);
    transform: scale(1.05);
  }
}

/* メニュー展開時: FABの背景色を変更 */
.c-fab.is-active {
  background-color: var(--fab-bg-active);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}

/* FABの1本線 */
.c-fab__line {
  display: block;
  width: 22px;
  height: 2px;
  background-color: #fff;
  border-radius: 2px;
  transform-origin: center;
  transition:
    transform 0.3s ease,
    opacity 0.3s ease;
}

/* ×(バツ)アニメーション */
.c-fab.is-active .c-fab__line:nth-child(1) {
  transform: translateY(7px) rotate(45deg);
}

.c-fab.is-active .c-fab__line:nth-child(2) {
  opacity: 0;
}

.c-fab.is-active .c-fab__line:nth-child(3) {
  transform: translateY(-7px) rotate(-45deg);
}

/* PCでもFABを表示する(ポートフォリオ向けのクリエイティブUIとして) */
/* 必要に応じて以下のコメントを外してPCでは非表示にできる */
/*
@media (min-width: 768px) {
  .c-fab {
    display: none;
  }
}
*/

/* ================================================
   c-expand-bg: 展開背景(右下から円形に広がるアニメーション)
   ================================================ */
.c-expand-bg {
  position: fixed;
  z-index: var(--z-overlay);
  pointer-events: none;

  /* FABボタンの中心を起点に配置 */
  bottom: calc(var(--fab-offset) + var(--fab-size) / 2);
  right: calc(var(--fab-offset) + var(--fab-size) / 2);

  /* 初期状態: 小さな円 */
  width: 0;
  height: 0;
  border-radius: 50%;
  background-color: var(--expand-bg);

  /* transformで拡大。中心を右下に固定するためtranslateで調整 */
  transform: translate(50%, 50%) scale(0);
  transition:
    transform var(--expand-transition-bg),
    width 0s,
    height 0s;

  opacity: 0;
}

/* 第1段階: 円形背景が画面全体に広がる */
.c-expand-bg.is-expanding {
  width: 300vmax;
  height: 300vmax;
  transform: translate(50%, 50%) scale(1);
  opacity: 1;
}

/* 閉じるとき: 逆順で縮小 */
.c-expand-bg.is-closing {
  width: 300vmax;
  height: 300vmax;
  transform: translate(50%, 50%) scale(0);
  opacity: 1;
  transition:
    transform var(--expand-transition-bg),
    opacity 0.1s ease 0.4s;
}

/* ================================================
   c-expand-nav: 展開型ナビゲーション
   ================================================ */
.c-expand-nav {
  position: fixed;
  inset: 0;
  z-index: var(--z-drawer);

  display: flex;
  align-items: center;
  justify-content: center;

  /* 初期状態: 非表示 */
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition:
    opacity var(--expand-transition-nav),
    visibility var(--expand-transition-nav);
}

/* 第2段階: ナビリンクがフェードイン */
.c-expand-nav.is-open {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
}

@media (min-width: 768px) {
  /* PCで非表示にしたい場合は以下のコメントを外す */
  /*
  .c-expand-nav {
    display: none;
  }
  */
}

/* ================================================
   c-expand-nav__list / item / link: メニューリスト
   ================================================ */
.c-expand-nav__list {
  list-style: none;
  margin: 0;
  padding: 0;
  text-align: center;
}

/* 各リンクの初期状態(非表示・下にずれた位置) */
.c-expand-nav__item {
  opacity: 0;
  transform: translateY(30px);
  transition:
    opacity var(--expand-transition-nav),
    transform var(--expand-transition-nav);
  /* 閉じるときは遅延なしで即座にフェードアウト */
  transition-delay: 0s;
}

/* is-open 時: 表示(元の位置に戻る) */
.c-expand-nav.is-open .c-expand-nav__item {
  opacity: 1;
  transform: translateY(0);
}

/* 各アイテムに遅延を付けてスタッガードアニメーションにする */
.c-expand-nav.is-open .c-expand-nav__item:nth-child(1) {
  transition-delay: calc(var(--expand-item-delay) * 1);
}

.c-expand-nav.is-open .c-expand-nav__item:nth-child(2) {
  transition-delay: calc(var(--expand-item-delay) * 2);
}

.c-expand-nav.is-open .c-expand-nav__item:nth-child(3) {
  transition-delay: calc(var(--expand-item-delay) * 3);
}

.c-expand-nav.is-open .c-expand-nav__item:nth-child(4) {
  transition-delay: calc(var(--expand-item-delay) * 4);
}

.c-expand-nav.is-open .c-expand-nav__item:nth-child(5) {
  transition-delay: calc(var(--expand-item-delay) * 5);
}

.c-expand-nav__link {
  display: block;
  padding: 16px 32px;
  color: #fff;
  text-decoration: none;
  font-size: 28px;
  font-weight: 600;
  letter-spacing: 0.1em;
  transition: color 0.2s ease;
}

@media (min-width: 768px) {
  .c-expand-nav__link {
    font-size: 36px;
    padding: 20px 40px;
  }
}

@media (any-hover: hover) and (pointer: fine) {
  .c-expand-nav__link:hover {
    color: #6eb5ff;
  }
}

JavaScript(assets/js/script.js)

/**
 * 007_hamburger-expand-corner
 * 右下から2段階展開型ハンバーガーメニュー
 */

(function () {
  "use strict";

  // 要素取得
  const fab = document.querySelector(".c-fab");
  const expandBg = document.querySelector(".c-expand-bg");
  const expandNav = document.querySelector(".c-expand-nav");

  if (!fab || !expandBg || !expandNav) return;

  // フォーカス可能な要素のセレクター
  const FOCUSABLE_SELECTOR =
    'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

  // 2段階展開のタイミング定数(CSSカスタムプロパティの値と合わせる)
  const PHASE1_DURATION = 400; // 第1段階(背景展開)の待機時間
  const PHASE2_FOCUS_DELAY = 500; // 第2段階完了後のフォーカス移動タイミング
  const CLOSE_NAV_DURATION = 300; // ナビフェードアウト待機時間
  const CLOSE_BG_DURATION = 500; // 背景縮小待機時間

  // 展開状態管理
  let isAnimating = false;

  /**
   * メニューを開く(2段階展開)
   */
  function openMenu() {
    if (isAnimating) return;
    isAnimating = true;

    fab.classList.add("is-active");
    fab.setAttribute("aria-expanded", "true");
    fab.setAttribute("aria-label", "メニューを閉じる");

    document.body.style.overflow = "hidden";

    expandBg.classList.remove("is-closing");
    expandBg.classList.add("is-expanding");

    setTimeout(function () {
      expandNav.classList.add("is-open");
      expandNav.setAttribute("aria-hidden", "false");

      const firstFocusable = expandNav.querySelectorAll(FOCUSABLE_SELECTOR)[0];
      if (firstFocusable) {
        setTimeout(function () {
          firstFocusable.focus();
        }, PHASE2_FOCUS_DELAY);
      }

      isAnimating = false;
    }, PHASE1_DURATION);
  }

  /**
   * メニューを閉じる(逆順で収納)
   */
  function closeMenu() {
    if (isAnimating) return;
    isAnimating = true;

    expandNav.classList.remove("is-open");
    expandNav.setAttribute("aria-hidden", "true");

    setTimeout(function () {
      expandBg.classList.remove("is-expanding");
      expandBg.classList.add("is-closing");

      fab.classList.remove("is-active");
      fab.setAttribute("aria-expanded", "false");
      fab.setAttribute("aria-label", "メニューを開く");

      document.body.style.overflow = "";

      setTimeout(function () {
        expandBg.classList.remove("is-closing");
        expandNav.style.visibility = "";
        isAnimating = false;
      }, CLOSE_BG_DURATION);
    }, CLOSE_NAV_DURATION);
  }

  function toggleMenu() {
    const isOpen = fab.classList.contains("is-active");
    if (isOpen) {
      closeMenu();
    } else {
      openMenu();
    }
  }

  /**
   * フォーカストラップ(FAB + 展開ナビ内をループ)
   */
  function trapFocus(e) {
    if (e.key !== "Tab") return;
    if (!expandNav.classList.contains("is-open")) return;

    const navFocusable = Array.from(expandNav.querySelectorAll(FOCUSABLE_SELECTOR));
    const focusableElements = [fab].concat(navFocusable);
    if (focusableElements.length === 0) return;

    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    if (e.shiftKey) {
      if (document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      }
    } else {
      if (document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    }
  }

  // FABクリック
  fab.addEventListener("click", toggleMenu);

  // キーボード操作(Escape / Tab)
  document.addEventListener("keydown", function (e) {
    if (e.key === "Escape" && expandNav.classList.contains("is-open")) {
      closeMenu();
      setTimeout(function () {
        fab.focus();
      }, CLOSE_NAV_DURATION + CLOSE_BG_DURATION);
      return;
    }
    if (e.key === "Tab") {
      trapFocus(e);
    }
  });

  // 画面幅が768px以上になったらメニューを閉じる
  const mediaQuery = window.matchMedia("(min-width: 768px)");
  mediaQuery.addEventListener("change", function (e) {
    if (e.matches) {
      if (expandNav.classList.contains("is-open")) {
        isAnimating = false;
        fab.classList.remove("is-active");
        expandBg.classList.remove("is-expanding", "is-closing");
        expandNav.classList.remove("is-open");

        fab.setAttribute("aria-expanded", "false");
        fab.setAttribute("aria-label", "メニューを開く");
        expandNav.setAttribute("aria-hidden", "true");

        document.body.style.overflow = "";
      }
      expandNav.removeAttribute("aria-hidden");
    } else {
      expandNav.setAttribute("aria-hidden", "true");
    }
  });
})();

カスタマイズポイント

コピペしたあと、自分のサイトに合わせて調整したい箇所を紹介します。

  • FABのサイズ・位置: :root--fab-size(56px)と --fab-offset(24px)を変更する
  • FABの背景色: --fab-bg を変更する(ブランドカラーに合わせると統一感が出やすい)
  • 展開背景の色: --expand-bg を変更する(ダークネイビーやカラフルな色も試しやすい)
  • 円形展開のスピード: --expand-transition-bg: 0.5s cubic-bezier(0.4, 0, 0.2, 1) を変更する
  • 注意: CSSの値を変えたら、JS側の PHASE1_DURATION / CLOSE_BG_DURATION も合わせて変更する
  • リンク出現の間隔: --expand-item-delay(0.08s)を変更する(大きくするとゆっくり、小さくすると素早く)
  • リンクのフォントサイズ・色: .c-expand-nav__linkfont-sizecolor を変更する
  • PCでFABを非表示にしたい: CSSの @media (min-width: 768px) { .c-fab { display: none; } } のコメントアウトを外す
  • FABを左下に置きたい: .c-fabrightleft に変更し、.c-expand-bgrightleft に置き換える。あわせて transform: translate(50%, 50%) の符号を調整する

よくある質問

300vmax って大きすぎませんか? もっと小さくても良いのでは?

100vmax だと画面の長辺と同じサイズで、画面の対角線(√2倍)には届かないため、四隅が円から少しはみ出すことがあります。200vmax あれば実用上は十分カバーできますが、確実に隅々まで覆えるよう余裕を見て 300vmax にしています。width / height を大きくしてもアニメーション対象は transform: scale なので、パフォーマンスへの影響はほとんどありません。

CSSの transition-delay だけでスタッガードを実装できるのに、なぜJavaScriptで setTimeout を使うのですか?

スタッガードアニメーション自体(リンクの順次表示)はCSSの transition-delay で完結しています。JS側の setTimeout が担当しているのは、「円形背景の展開(第1段階)が終わったタイミングで、ナビ要素に is-open クラスを付ける(第2段階開始)」という”段階の切り替え”の部分です。CSSアニメーションには「終わったら次のクラスを付ける」というイベント連動が直接書けないため、JSで明示的にタイミングを制御しています。

CSS変数とJSの定数を別々に管理するのは二重管理ではありませんか?

二重管理になっているのは事実です。より厳密にやりたい場合は、getComputedStyle(document.documentElement).getPropertyValue('--expand-transition-bg') でCSS変数の値を読み取り、parseFloat でミリ秒に変換する方法もあります。ただし、コードが少し長くなるのと、CSS変数の文字列パースに気を配る必要が出てくるため、このスニペットでは「コメントで連動を明示して、目視で揃える」シンプルな方針を採っています。

このFABはPCでも常に表示されるのが正解ですか?

ポートフォリオ・クリエイティブ系サイトでは、PCでもFABを表示したまま「クリエイティブな印象を残す装置」として使うケースがあります。一方で、業務系サイトやコンテンツ重視のサイトでは「PCでは通常の横並びナビ、SPだけでFAB」という構成の方が一般的です。CSSのコメントアウト箇所を外すだけでPC非表示に切り替えられるので、サイトの性質に合わせて選んでみてください。

@media (any-hover: hover) and (pointer: fine) を書かないと、何が起きますか?

書かずに :hover を直接使うと、スマホのタッチ操作でも一瞬ホバースタイルが適用されて、タップ後もそのスタイルが残ってしまうことがあります(タップしたボタンが拡大されたまま戻らない、など)。any-hover: hoverpointer: fine の両方を満たすデバイス(実質的にPC+マウス)に限定することで、タッチ操作時の余計な挙動を防げます。

まとめ

この記事では、右下のFABから円形に広がる2段階アニメーション型のハンバーガーメニューを、CSSとJavaScriptだけで実装する方法を解説しました。ポイントを振り返ります。

  • FAB(Floating Action Button)は画面右下に常時配置されるボタンで、ポートフォリオ・クリエイティブ系サイトでは差別化の武器になる
  • 円形背景の展開アニメーションtransform: scalecubic-bezier(0.4, 0, 0.2, 1) で、自然かつリッチな動きに仕上げる
  • スタッガードアニメーション:nth-child()transition-delay: calc() の組み合わせで、リンクが順次出現する演出が作れる
  • 2段階アニメーションはJSの setTimeout でタイミングを制御し、CSSカスタムプロパティとJS側の定数の値を必ず合わせて運用する
  • フォーカストラップにFABを含めることで、閉じるボタンがなくてもキーボード操作で確実に閉じられる

私自身、ポートフォリオやクライアント案件で「ただのハンバーガーメニュー」だとどうしても印象が薄くなってしまうな、と感じていた時期がありました。色々試した中で、cubic-bezier をひとつ入れるだけで動きの質感が大きく変わることに気づき、そこから「リッチに見えるけど中身はシンプルなCSS+JS」を意識するようになりました。

2段階アニメーションは見た目のインパクトが大きい一方、タイミングの管理を雑にすると一気にチカチカした印象になってしまいます。CSSとJSをまたぐ値を定数で揃えて、コメントで意図を残しておく——この地味な習慣が、後から自分が触り直すときにも、別の人に渡すときにも効いてくるな、と実感しています。

まずはGitHub Pagesのデモで動きを確認して、気に入ったらGitHubリポジトリからコードを持ち帰ってみてください。カスタムプロパティを変えるだけで自分のサイトの雰囲気に合わせられるので、ポートフォリオの仕上げにぜひ試してみてください。