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

ナビゲーション 中級

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

管理画面やダッシュボードの左側に配置するナビゲーションメニューのコンポーネントです。JSONファイルにメニュー項目を定義するだけで、JavaScriptがサイドバーHTMLを自動生成します。各項目は子メニューを持てる2階層のツリー構造に対応しており、クリックでスムーズなアニメーション付きの展開・折りたたみができます。サイドバー全体をアイコンのみのコンパクト表示(幅64px)に切り替えるトグル機能も備え、コンテンツ領域を広く使いたい場面に対応します。受注管理・商品管理・設定など、業務アプリの管理画面を想定した実用的なサンプルデータを含みます。

  • JSONドリブン生成data/data.json に定義したメニューデータからサイドバーHTMLを動的生成。項目の追加・削除はJSONの編集のみで完結
  • ツリー展開・折りたたみ — 子項目を持つ親メニューはクリックで展開/折りたたみ。複数の親メニューを同時に開ける
  • サイドバー全体の縮小 — ◀ボタンでサイドバー幅を240px ⇔ 64pxにCSSトランジションで切り替え。縮小時はアイコンのみ表示
  • ツールチップ(縮小時) — 縮小状態で各メニューにホバーするとラベルをツールチップ表示(CSS ::after で実装)
  • アクティブ状態管理 — クリックした項目をアクティブ表示(背景色・テキスト色変更)
  • 状態のlocalStorage保存 — 展開中の親メニュー・アクティブ項目・サイドバーの縮小状態をlocalStorageに記録し、リロード後も復元

実装のポイント・注意点

ツリーの展開アニメーションには max-height よりも CSS Grid の grid-template-rows: 0fr → 1fr が適しています。max-height は実際の要素高さと無関係に時間が決まるため、開くときは速く閉じるときは遅くなる不自然な動作になりがちです。grid-template-rows なら実際の高さに追随して一定速度でアニメーションします。ただし、この手法には内側ラッパー要素(div.nav-children-inner)が必須です。ラッパーに min-height: 0 を指定しないと、0fr 時も内容が見えてしまうため注意してください。

縮小時のツールチップは CSS の ::after 疑似要素と content: attr(data-label) で実装しています。JavaScriptが要素生成時に data-label 属性にラベルをセットし、CSSがそれを参照して表示します。サイドバーに overflow: hidden を設定するとツールチップが枠内に隠れてしまうため、overflow: visible(またはデフォルト)のままにしてください。JSONから取得したテキストを innerHTML に直接代入するとXSSリスクがあるため、textContent で設定するか createElement で要素を組み立てることを徹底してください。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。ただし、JSONファイルの読み込みに fetch を使用しているため、file:// でブラウザに直接開いた場合はCORSエラーが発生します。VS Code Live Server等のローカルサーバー経由で動作確認してください。

デモ

受注一覧

サイドバーのメニューをクリックするとページが切り替わります。

◀ ボタンでサイドバーを折りたたむとコンテンツ領域が広がります。

サンプルソース

4つのファイルを同じフォルダに保存し、ローカルサーバー(VS Code Live Server等)経由で index.html を開くと動作確認できます。
ファイル名:index.html / style.css / script.js + data/ フォルダに data.jsonfetch を使用しているため file:// での直接表示は動作しません(CORSエラー)。保存時の文字コードは UTF-8 を指定してください。

<!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="sidebar" id="sidebar">
      <div class="sidebar-header">
        <span class="sidebar-logo">🚀</span>
        <span class="sidebar-app-name">MyApp</span>
        <button class="sidebar-toggle" id="sidebarToggle" aria-label="サイドバーの開閉">
          <span class="toggle-icon">◀</span>
        </button>
      </div>
      <nav class="sidebar-nav" id="sidebarNav" aria-label="メインナビゲーション">
        <ul class="nav-list" id="navList"></ul>
      </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 {
  --sidebar-width: 240px;
  --sidebar-collapsed-width: 64px;
  --sidebar-bg: #1e2433;
  --sidebar-text: #c9d1e0;
  --sidebar-hover-bg: rgba(255, 255, 255, 0.07);
  --active-bg: rgba(99, 179, 237, 0.15);
  --active-color: #63b3ed;
  --border-color: rgba(255, 255, 255, 0.08);
}

* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: sans-serif; font-size: 14px; }

/* レイアウト */
.app-layout {
  display: flex;
  height: 100vh;
  overflow: hidden;
}

/* サイドバー */
.sidebar {
  width: var(--sidebar-width);
  flex-shrink: 0;
  background: var(--sidebar-bg);
  color: var(--sidebar-text);
  display: flex;
  flex-direction: column;
  transition: width 0.25s ease;
  overflow: visible; /* ツールチップが枠外に出るよう visible に */
  position: relative;
  z-index: 10;
}

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

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

.sidebar-logo {
  font-size: 20px;
  flex-shrink: 0;
  width: 32px;
  text-align: center;
  line-height: 1;
}

.sidebar-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;
}

.sidebar-toggle {
  background: none;
  border: none;
  color: var(--sidebar-text);
  cursor: pointer;
  padding: 4px 6px;
  border-radius: 4px;
  flex-shrink: 0;
  font-size: 11px;
  line-height: 1;
  transition: background 0.15s;
  margin-left: auto;
}

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

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

/* 縮小時 */
.sidebar.collapsed .sidebar-app-name { opacity: 0; max-width: 0; }
.sidebar.collapsed .toggle-icon { transform: rotate(180deg); }

/* ナビゲーション */
.sidebar-nav {
  flex: 1;
  overflow-y: auto;
  overflow-x: hidden;
  padding: 8px 0;
}

.nav-list { list-style: none; padding: 0; margin: 0; }
.nav-item { position: relative; }

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

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

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

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

.chevron {
  font-size: 9px;
  flex-shrink: 0;
  transition: transform 0.2s ease, opacity 0.25s ease;
}

.nav-item.open > .nav-parent-btn .chevron { transform: rotate(180deg); }

/* 縮小時:ラベル・シェブロン非表示 */
.sidebar.collapsed .nav-label { opacity: 0; max-width: 0; }
.sidebar.collapsed .chevron { opacity: 0; max-width: 0; }

/* 縮小時ツールチップ */
.sidebar.collapsed .nav-parent-btn { position: relative; }

.sidebar.collapsed .nav-parent-btn::after {
  content: attr(data-label);
  position: absolute;
  left: 60px;
  top: 50%;
  transform: translateY(-50%);
  background: #2d3748;
  color: #fff;
  padding: 5px 10px;
  border-radius: 6px;
  font-size: 12px;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.15s;
  z-index: 100;
  box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}

.sidebar.collapsed .nav-parent-btn:hover::after { opacity: 1; }

/* 子メニュー(CSS Grid アニメーション) */
.nav-children {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.25s ease;
  list-style: none;
  padding: 0;
  margin: 0;
}

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

/* min-height: 0 必須(ないと 0fr でも内容が見えてしまう) */
.nav-children-inner { min-height: 0; overflow: hidden; }

.nav-child-link {
  display: block;
  padding: 8px 12px 8px 54px;
  color: var(--sidebar-text);
  text-decoration: none;
  font-size: 12.5px;
  white-space: nowrap;
  transition: background 0.15s, color 0.15s;
}

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

/* 縮小時:子メニュー非表示 */
.sidebar.collapsed .nav-children { grid-template-rows: 0fr !important; }

/* メインコンテンツ */
.main-content {
  flex: 1;
  overflow-y: auto;
  padding: 28px 32px;
  background: #f7f9fc;
}

.main-content h2 { font-size: 18px; color: #1a202c; margin-bottom: 8px; }
.page-desc { font-size: 13px; color: #718096; }
// ※ file:// での直接表示は CORS エラーになります。VS Code Live Server 等で実行してください
fetch('./data/data.json')
  .then(function(res) { return res.json(); })
  .then(function(data) {
    buildSidebar(data.nav);
    if (!restoreState()) {
      setInitialState();
    }
  })
  .catch(function() {
    document.getElementById('navList').textContent = 'データ読み込み失敗。ローカルサーバーで実行してください。';
  });

// JSONデータからサイドバーのDOM要素を生成
function buildSidebar(items) {
  var list = document.getElementById('navList');
  if (!list) return;

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

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

      var icon = document.createElement('span');
      icon.className = 'nav-icon';
      icon.textContent = item.icon;       // textContent でXSS対策

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

      var chevron = document.createElement('span');
      chevron.className = 'chevron';
      chevron.textContent = '▼';

      btn.appendChild(icon);
      btn.appendChild(label);
      btn.appendChild(chevron);
      btn.addEventListener('click', function() { toggleParent(li); });

      // 子メニューリスト(Grid アニメーション用の2層構造)
      var ul = document.createElement('ul');
      ul.className = 'nav-children';

      var inner = document.createElement('div');
      inner.className = 'nav-children-inner';

      item.children.forEach(function(child) {
        var childLi = document.createElement('li');
        var childLink = document.createElement('a');
        childLink.className = 'nav-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);
      });

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

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

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

      var label2 = document.createElement('span');
      label2.className = '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 sidebar = document.getElementById('sidebar');
  // 縮小中にクリックされた場合はサイドバーを先に展開する
  if (sidebar.classList.contains('collapsed')) {
    sidebar.classList.remove('collapsed');
    localStorage.setItem('sb_collapsed', 'false');
  }
  var isOpen = li.classList.toggle('open');
  var btn = li.querySelector('.nav-parent-btn');
  if (btn) btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
  saveState();
}

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

// サイドバートグルボタン
document.getElementById('sidebarToggle').addEventListener('click', function() {
  var sidebar = document.getElementById('sidebar');
  var collapsed = sidebar.classList.toggle('collapsed');
  localStorage.setItem('sb_collapsed', collapsed ? 'true' : 'false');
});

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

  var activeEl = document.querySelector('.nav-child-link.active, .nav-solo-link.active');
  if (activeEl) localStorage.setItem('sb_activeItem', activeEl.dataset.id || '');
}

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

  if (localStorage.getItem('sb_collapsed') === 'true') {
    document.getElementById('sidebar').classList.add('collapsed');
  }

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

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

  return hasState;
}

// 初期状態(受注管理展開・受注一覧アクティブ)を設定
function setInitialState() {
  var ordersItem = document.querySelector('.nav-item[data-id="orders"]');
  if (ordersItem) {
    ordersItem.classList.add('open');
    var btn = ordersItem.querySelector('.nav-parent-btn');
    if (btn) btn.setAttribute('aria-expanded', 'true');
  }
  setActive('orders-list', '受注一覧');
}
{
  "nav": [
    {
      "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": "#" }
      ]
    }
  ]
}

AI用プロンプト

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

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

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

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

# サイドバーナビゲーション 作成依頼

## 概要
管理画面・ダッシュボード用のサイドバーナビゲーションを作成してください。
JSONファイルからメニューを動的生成し、ツリー展開とサイドバー全体の折りたたみに対応したコンポーネントです。

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

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

## 動作詳細
- サイドバーの展開幅は240px、縮小幅は64px。CSSトランジションで切り替える
- ツリーの展開アニメーションは CSS Grid(grid-template-rows: 0fr → 1fr)で実装する(max-heightより滑らか)
- grid-template-rowsトランジションには内側ラッパー要素(div.nav-children-inner)が必要
- 縮小時のツールチップは CSS の ::after 疑似要素で実装する(data-label属性を content: attr(data-label) で表示)
- XSS対策:JSONデータを innerHTML に直接結合せず、createElement + textContent で組み立てること

## 出力形式
HTML・CSS・JavaScript・JSONを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。
ファイル名:index.html / style.css / script.js + data/ フォルダに data.json