サイドバーナビゲーション(Sidebar Navigation)— アイコン折りたたみ型

ナビゲーション 中級

このコンポーネントについて

管理画面やダッシュボードの左側に配置する、アイコンのみのミニサイドバー(icon rail)です。トグルボタンでラベル付きのフル幅表示とアイコンのみの折りたたみ表示を切り替えられます。折りたたみ時に子メニューを持つ項目へマウスホバーすると、子メニュー一覧をフライアウトパネルで表示するため、幅を絞った状態でも階層メニューへすぐアクセスできます。VSCodeのアクティビティバーやSlackのワークスペース切替のような、実務でよく見る「常時省スペース」寄りのサイドバーパターンです。ツリー展開型の navigation-sidebar-1 とは異なる操作体験として、幅を優先したい管理画面向けに使えます。

  • フル幅⇔アイコンレールのトグル切替 — トグルボタンでサイドバー幅を240px(ラベル付き)⇔64px(アイコンのみ)にCSSトランジションで切り替え
  • 折りたたみ時のフライアウトメニュー — アイコンレール状態で子メニューを持つ項目にホバーすると、右側に子メニュー一覧をパネル表示。ツールチップと違いクリックまで完結できる
  • 展開時のインラインアコーディオン — フル幅状態では親メニュークリックで子メニューがインライン展開・折りたたみ(複数同時展開可)
  • アクティブ状態管理 — クリックした項目をアクティブ表示(背景色・テキスト色変更)し、メインコンテンツの見出しを連動更新
  • 状態のlocalStorage保存 — 折りたたみ/展開の状態・アクティブ項目・展開中の親メニューをlocalStorageに記録し、リロード後も復元

実装のポイント・注意点

子メニューの一覧(.sr-children)は、展開時のインラインアコーディオンと折りたたみ時のフライアウトパネルで同じDOM要素を使い回します。展開時は display: grid; grid-template-rows: 0fr → 1fr でその場に開閉し、折りたたみ時は同じ要素を position: absolute; left: 100% に切り替えてパネル化するだけです。表示の出し分けはすべてCSSのクラス切り替えで完結し、JavaScript側で「開いているか」のフラグを二重に持つ必要がありません。

フライアウトパネルの表示・非表示は :hover:focus-within の2つの疑似クラスだけで制御しています。:focus-within を使うことで、マウスユーザーだけでなく Tab キーで移動するキーボードユーザーもフライアウト内のリンクへアクセスできます。JavaScriptでopen/closeの状態を管理すると、マウスとキーボードの操作が競合してズレるバグを生みやすいため、あえてCSSだけに任せています。

折りたたみ状態のときに親メニューのボタンをクリックしても、インラインアコーディオンは開きません(フライアウトのみで案内します)。もし折りたたみ状態でもクリックで開閉できてしまうと、画面上には見えない位置(幅64pxの外)で子メニューが展開されてしまう不具合になるため、クリックハンドラー内で折りたたみ状態を判定してearly returnしています。また、フライアウトパネルがサイドバーの枠外にはみ出して表示されるよう、.sr-sidebar には overflow: hidden を設定せず overflow: visible(デフォルト)のままにしておく必要があります。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。

デモ

ダッシュボード

折りたたみ状態のアイコンにホバーすると、子メニューがフライアウトで開きます。

トグルボタン(›)でラベル付きのフル幅表示にも切り替えられます。

サンプルソース

3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js — 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>サイドバーナビゲーション(アイコン折りたたみ型)</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <div class="app-layout">
    <!-- サイドバー(初期状態は折りたたみ) -->
    <aside class="sr-sidebar sr-collapsed" id="sidebarRail">
      <div class="sr-header">
        <span class="sr-logo">🚀</span>
        <span class="sr-app-name">MyApp</span>
        <button class="sr-toggle" id="railToggle" aria-label="サイドバーの開閉" aria-expanded="false">
          <span class="sr-toggle-icon">›</span>
        </button>
      </div>
      <nav class="sr-nav" id="railNav" aria-label="メインナビゲーション">
        <ul class="sr-nav-list" id="railList"></ul>
        <!-- script.js が NAV_DATA から動的生成 -->
      </nav>
    </aside>

    <!-- メインコンテンツ -->
    <main class="main-content" id="mainContent">
      <h2 id="pageTitle">ダッシュボード</h2>
      <p class="page-desc">アイコンにホバーすると子メニューが開きます。トグルで展開表示にも切り替えられます。</p>
    </main>
  </div>

  <script src="./script.js"></script>
</body>
</html>
:root {
  --sr-width: 64px;
  --sr-expanded-width: 240px;
  --sr-bg: #1e2433;
  --sr-text: #c9d1e0;
  --sr-text-muted: #8b96ab;
  --sr-hover-bg: rgba(255, 255, 255, 0.07);
  --sr-active-bg: rgba(99, 179, 237, 0.15);
  --sr-active-color: #63b3ed;
  --sr-border: rgba(255, 255, 255, 0.1);
}

*, *::before, *::after { box-sizing: border-box; }

body {
  font-family: sans-serif;
  margin: 0;
}

.app-layout {
  display: flex;
  height: 100vh;
}

/* ===== サイドバー(アイコンレール) ===== */
.sr-sidebar {
  width: var(--sr-expanded-width);
  flex-shrink: 0;
  background: var(--sr-bg);
  color: var(--sr-text);
  display: flex;
  flex-direction: column;
  transition: width 0.25s ease;
  /* フライアウトパネルが枠外に出るため visible のままにする */
  overflow: visible;
  position: relative;
}

.sr-sidebar.sr-collapsed {
  width: var(--sr-width);
}

/* ===== サイドバーヘッダー ===== */
.sr-header {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 14px 12px;
  border-bottom: 1px solid var(--sr-border);
  min-height: 54px;
  overflow: hidden;
}

.sr-logo { font-size: 20px; flex-shrink: 0; width: 32px; text-align: center; }

.sr-app-name {
  font-weight: 700;
  font-size: 14px;
  color: #fff;
  white-space: nowrap;
  flex: 1;
  overflow: hidden;
  transition: opacity 0.25s ease, max-width 0.25s ease;
  max-width: 120px;
}

.sr-sidebar.sr-collapsed .sr-app-name { opacity: 0; max-width: 0; }

.sr-toggle {
  background: none;
  border: none;
  color: var(--sr-text);
  cursor: pointer;
  padding: 4px 6px;
  border-radius: 4px;
  font-size: 14px;
  margin-left: auto;
}

.sr-toggle:hover { background: var(--sr-hover-bg); }

.sr-toggle-icon { display: inline-block; transition: transform 0.25s ease; }

/* 展開時はアイコンを反転(右向き→左向き) */
.sr-sidebar:not(.sr-collapsed) .sr-toggle-icon { transform: rotate(180deg); }

/* ===== ナビゲーションリスト =====
   overflow は visible のままにする。hidden/auto にすると、折りたたみ時の
   フライアウトパネル(サイドバー幅の外に出る絶対配置要素)がクリップされて見えなくなる */
.sr-nav { flex: 1; overflow: visible; padding: 8px 0; }
.sr-nav-list { list-style: none; padding: 0; margin: 0; }
.sr-nav-item { position: relative; }

/* 親メニューボタン・単独リンク共通 */
.sr-parent-btn {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 10px 12px;
  background: none;
  border: none;
  color: var(--sr-text);
  font-size: 13px;
  font-family: inherit;
  text-align: left;
  cursor: pointer;
  white-space: nowrap;
  text-decoration: none;
}

.sr-parent-btn:hover { background: var(--sr-hover-bg); color: #fff; }

.sr-nav-icon { font-size: 17px; flex-shrink: 0; width: 32px; text-align: center; }

.sr-nav-label {
  flex: 1;
  overflow: hidden;
  transition: opacity 0.25s ease, max-width 0.25s ease;
  max-width: 140px;
}

.sr-chevron { font-size: 9px; flex-shrink: 0; transition: transform 0.2s ease; }
.sr-nav-item.sr-open > .sr-parent-btn .sr-chevron { transform: rotate(180deg); }

/* 折りたたみ時:ラベル・シェブロン非表示 */
.sr-sidebar.sr-collapsed .sr-nav-label,
.sr-sidebar.sr-collapsed .sr-chevron {
  opacity: 0;
  max-width: 0;
}

/* ===== 子メニュー =====
   展開時:インラインアコーディオン(grid-template-rows)
   折りたたみ時:同じ要素をフライアウトパネルに転用 */
.sr-children {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.2s ease;
  list-style: none;
  padding: 0;
  margin: 0;
}

.sr-nav-item.sr-open > .sr-children { grid-template-rows: 1fr; }

/* min-height: 0 が無いと 0fr でも中身が見えてしまう */
.sr-children-inner { min-height: 0; overflow: hidden; }

.sr-flyout-label {
  display: none;
  padding: 8px 14px;
  font-size: 11px;
  font-weight: 700;
  color: var(--sr-text-muted);
  border-bottom: 1px solid var(--sr-border);
  white-space: nowrap;
}

.sr-child-link {
  display: block;
  padding: 8px 14px 8px 54px;
  color: var(--sr-text);
  text-decoration: none;
  font-size: 12.5px;
  white-space: nowrap;
}

.sr-child-link:hover { background: var(--sr-hover-bg); color: #fff; }
.sr-child-link.sr-active,
.sr-solo-link.sr-active { background: var(--sr-active-bg); color: var(--sr-active-color); }

/* ===== 折りたたみ時:フライアウトパネル化 ===== */
.sr-sidebar.sr-collapsed .sr-children {
  position: absolute;
  left: 100%;
  top: 0;
  min-width: 180px;
  display: block;
  grid-template-rows: unset;
  background: var(--sr-bg);
  border: 1px solid var(--sr-border);
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
  padding: 4px 0;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.15s ease;
  z-index: 200;
}

.sr-sidebar.sr-collapsed .sr-flyout-label { display: block; }

.sr-sidebar.sr-collapsed .sr-nav-item:hover > .sr-children,
.sr-sidebar.sr-collapsed .sr-nav-item:focus-within > .sr-children {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
}

.sr-sidebar.sr-collapsed .sr-child-link { padding-left: 14px; }

/* ===== メインコンテンツエリア ===== */
.main-content { flex: 1; overflow-y: auto; padding: 28px 32px; background: #f7f9fc; }
.main-content h2 { font-size: 18px; color: #1a202c; margin: 0 0 10px; }
.page-desc { font-size: 13px; color: #718096; margin: 0 0 8px; }
// メニューデータ(配列で保持。fetchは使わない)
var NAV_DATA = [
  { id: "dashboard", label: "ダッシュボード", icon: "🏠", href: "#" },
  {
    id: "orders", label: "受注管理", icon: "📦",
    children: [
      { id: "orders-list", label: "受注一覧", href: "#" },
      { id: "orders-new", label: "新規受注", href: "#" },
      { id: "orders-history", label: "受注履歴", href: "#" }
    ]
  },
  {
    id: "products", label: "商品管理", icon: "🛍️",
    children: [
      { id: "products-list", label: "商品一覧", href: "#" },
      { id: "products-new", label: "商品追加", href: "#" }
    ]
  },
  { id: "customers", label: "顧客管理", icon: "👥", href: "#" },
  {
    id: "reports", label: "レポート", icon: "📊",
    children: [
      { id: "reports-sales", label: "売上レポート", href: "#" },
      { id: "reports-access", label: "アクセス解析", href: "#" }
    ]
  },
  {
    id: "settings", label: "設定", icon: "⚙️",
    children: [
      { id: "settings-account", label: "アカウント", href: "#" },
      { id: "settings-plan", label: "プラン・契約", href: "#" },
      { id: "settings-users", label: "メンバー管理", href: "#" }
    ]
  }
];

var DEFAULT_ACTIVE_ID = "dashboard";
var DEFAULT_ACTIVE_LABEL = "ダッシュボード";

buildRail(NAV_DATA);
if (!restoreState()) {
  setActive(DEFAULT_ACTIVE_ID, DEFAULT_ACTIVE_LABEL);
}
bindToggle();

// NAV_DATA からサイドバーのDOM要素を生成
// (innerHTMLではなく createElement + textContent で組み立ててXSSを防止)
function buildRail(items) {
  var list = document.getElementById("railList");
  if (!list) return;

  items.forEach(function (item) {
    var li = document.createElement("li");
    li.className = "sr-nav-item";
    li.dataset.id = item.id;

    if (item.children && item.children.length > 0) {
      // 親メニュー(子あり)
      var btn = document.createElement("button");
      btn.className = "sr-parent-btn";
      btn.setAttribute("aria-expanded", "false");

      var icon = document.createElement("span");
      icon.className = "sr-nav-icon";
      icon.textContent = item.icon;

      var label = document.createElement("span");
      label.className = "sr-nav-label";
      label.textContent = item.label;

      var chevron = document.createElement("span");
      chevron.className = "sr-chevron";
      chevron.textContent = "▼";

      btn.appendChild(icon);
      btn.appendChild(label);
      btn.appendChild(chevron);

      // 折りたたみ状態のときはクリックしても開かない(フライアウトのみで案内)
      btn.addEventListener("click", function () {
        var rail = document.getElementById("sidebarRail");
        if (rail.classList.contains("sr-collapsed")) return;
        toggleParent(li);
      });

      // 子メニューリスト
      // 展開時:インラインアコーディオン / 折りたたみ時:同じ要素をフライアウトに転用
      var childrenUl = document.createElement("ul");
      childrenUl.className = "sr-children";

      var inner = document.createElement("div");
      inner.className = "sr-children-inner";

      var flyoutLabel = document.createElement("p");
      flyoutLabel.className = "sr-flyout-label";
      flyoutLabel.textContent = item.label;
      inner.appendChild(flyoutLabel);

      item.children.forEach(function (child) {
        var childLi = document.createElement("li");
        var childLink = document.createElement("a");
        childLink.className = "sr-child-link";
        childLink.href = child.href;
        childLink.dataset.id = child.id;
        childLink.textContent = child.label;
        childLink.addEventListener("click", function (e) {
          e.preventDefault();
          setActive(child.id, child.label);
        });
        childLi.appendChild(childLink);
        inner.appendChild(childLi);
      });

      childrenUl.appendChild(inner);
      li.appendChild(btn);
      li.appendChild(childrenUl);

    } else {
      // 単独メニュー(子なし)
      var link = document.createElement("a");
      link.className = "sr-parent-btn sr-solo-link";
      link.href = item.href;
      link.dataset.id = item.id;

      var icon2 = document.createElement("span");
      icon2.className = "sr-nav-icon";
      icon2.textContent = item.icon;

      var label2 = document.createElement("span");
      label2.className = "sr-nav-label";
      label2.textContent = item.label;

      link.appendChild(icon2);
      link.appendChild(label2);
      link.addEventListener("click", function (e) {
        e.preventDefault();
        setActive(item.id, item.label);
      });
      li.appendChild(link);
    }

    list.appendChild(li);
  });
}

// 親メニューの展開/折りたたみをトグル(展開状態のときのみ呼ばれる)
function toggleParent(li) {
  var isOpen = li.classList.toggle("sr-open");
  var btn = li.querySelector(".sr-parent-btn");
  if (btn) btn.setAttribute("aria-expanded", isOpen ? "true" : "false");
  saveState();
}

// アクティブ項目を設定してメインコンテンツのタイトルを更新
function setActive(id, label) {
  document.querySelectorAll("#railList .sr-child-link, #railList .sr-solo-link").forEach(function (el) {
    el.classList.remove("sr-active");
  });
  var target = document.querySelector('#railList [data-id="' + id + '"]');
  if (target) target.classList.add("sr-active");
  var title = document.getElementById("pageTitle");
  if (title) title.textContent = label + " ページ";
  saveState();
}

// トグルボタン(フル幅⇔アイコンレール)
function bindToggle() {
  document.getElementById("railToggle").addEventListener("click", function () {
    var rail = document.getElementById("sidebarRail");
    var collapsed = rail.classList.toggle("sr-collapsed");
    this.setAttribute("aria-expanded", collapsed ? "false" : "true");
    localStorage.setItem("sbr_collapsed", collapsed ? "true" : "false");
  });
}

// 現在の状態を localStorage に保存
function saveState() {
  var openItems = [];
  document.querySelectorAll("#railList .sr-nav-item.sr-open").forEach(function (el) {
    if (el.dataset.id) openItems.push(el.dataset.id);
  });
  localStorage.setItem("sbr_openItems", JSON.stringify(openItems));

  var activeEl = document.querySelector("#railList .sr-child-link.sr-active, #railList .sr-solo-link.sr-active");
  if (activeEl) localStorage.setItem("sbr_activeItem", activeEl.dataset.id || "");
}

// localStorage から状態を復元(状態があれば true を返す)
function restoreState() {
  var hasState = false;

  if (localStorage.getItem("sbr_collapsed") === "false") {
    document.getElementById("sidebarRail").classList.remove("sr-collapsed");
    document.getElementById("railToggle").setAttribute("aria-expanded", "true");
  }

  var openItems = JSON.parse(localStorage.getItem("sbr_openItems") || "null");
  if (openItems) {
    hasState = true;
    openItems.forEach(function (id) {
      var li = document.querySelector('#railList .sr-nav-item[data-id="' + id + '"]');
      if (li) {
        li.classList.add("sr-open");
        var btn = li.querySelector(".sr-parent-btn");
        if (btn) btn.setAttribute("aria-expanded", "true");
      }
    });
  }

  var activeId = localStorage.getItem("sbr_activeItem");
  if (activeId) {
    hasState = true;
    var target = document.querySelector('#railList [data-id="' + activeId + '"]');
    if (target) {
      target.classList.add("sr-active");
      var title = document.getElementById("pageTitle");
      if (title) title.textContent = target.textContent.trim() + " ページ";
    }
  }

  return hasState;
}

// リセット:localStorageをクリアして初期状態(折りたたみ・ダッシュボード)に戻す
function resetDemo() {
  localStorage.removeItem("sbr_openItems");
  localStorage.removeItem("sbr_activeItem");
  localStorage.removeItem("sbr_collapsed");

  var rail = document.getElementById("sidebarRail");
  rail.classList.add("sr-collapsed");
  document.getElementById("railToggle").setAttribute("aria-expanded", "false");

  document.querySelectorAll("#railList .sr-nav-item.sr-open").forEach(function (el) {
    el.classList.remove("sr-open");
    var btn = el.querySelector(".sr-parent-btn");
    if (btn) btn.setAttribute("aria-expanded", "false");
  });

  document.querySelectorAll("#railList .sr-active").forEach(function (el) {
    el.classList.remove("sr-active");
  });

  setActive(DEFAULT_ACTIVE_ID, DEFAULT_ACTIVE_LABEL);
}

AI用プロンプト

以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。メニュー項目の追加やアイコンの変更など、要件を追記して使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# サイドバーナビゲーション(アイコン折りたたみ型)作成依頼

## 概要
管理画面・ダッシュボード用の、アイコン折りたたみ式ミニサイドバーを作成してください。
トグルボタンでラベル付きのフル幅表示とアイコンのみの折りたたみ表示を切り替えられ、折りたたみ時は子メニューをホバーでフライアウト表示するコンポーネントです。

## 要件
- メニューデータはJavaScript内の配列で保持し、動的にサイドバーHTMLを生成する
- 親メニューと子メニューの2階層ツリー構造(子なし単独メニューも可)
- 各メニュー項目は id・label・icon(絵文字)・href を持つ(子ありの場合は children 配列を追加)
- トグルボタンでサイドバー幅を240px(ラベル付き)⇔64px(アイコンのみ)に切り替える
- 折りたたみ状態で、子メニューを持つアイコンにマウスホバーすると子メニュー一覧をフライアウトパネルで表示する
- 展開状態では、親メニュークリックで子メニューをインラインでアコーディオン展開・折りたたみする(複数同時展開可)
- クリックした項目をアクティブ状態に変化させる
- 折りたたみ/展開の状態・アクティブ項目・展開中の親メニューを localStorage に保存し、リロード後も復元する

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:不要(PCレイアウト固定。最小幅600px以上を想定)

## 動作詳細
- サイドバー幅はCSSトランジションで切り替える(240px ⇔ 64px)
- フライアウトパネルの表示はCSSの :hover と :focus-within で制御し、キーボード操作でも表示できるようにする
- インラインアコーディオンの展開アニメーションは CSS Grid(grid-template-rows: 0fr → 1fr)で実装する(max-heightより滑らか)
- grid-template-rowsトランジションには内側ラッパー要素が必要
- XSS対策:メニューデータを innerHTML に直接結合せず、createElement + textContent で組み立てること

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。