SCALE — Build Lab
UI部品 · REACT COMPONENT

左サイドバーナビ(SystemSidebar)

CATEGORYUI部品 TYPEReact Component EFFORT30〜90分 DIFFICULTY
PRIMARY CODE
tsx · components/layout/SystemSidebar.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { LayoutDashboard, Settings /* etc */ } from 'lucide-react';
import type { ComponentType, SVGProps } from 'react';

type IconType = ComponentType<SVGProps<SVGSVGElement> & { size?: number | string }>;
type Item = { label: string; href: string; icon: IconType; aliases?: string[] };
type Group = { name: string; items: Item[] };

// ─── ここをカスタマイズ ───
const GROUPS: Group[] = [
  {
    name: '全体',
    items: [
      { label: 'ダッシュボード', href: '/', icon: LayoutDashboard },
    ],
  },
  {
    name: '設定',
    items: [
      { label: 'アカウント', href: '/account', icon: Settings },
    ],
  },
];

function normalize(p: string) {
  if (!p) return '/';
  const trimmed = p.replace(/\/+$/, '');
  return trimmed === '' ? '/' : trimmed;
}

export default function SystemSidebar() {
  const pathname = usePathname() || '/';
  const current = normalize(pathname);

  return (
    <aside
      className="hidden md:flex md:flex-col w-[240px] shrink-0 bg-bg2 border-r border-border overflow-y-auto"
      style={{ height: 'calc(100vh - 44px)', position: 'sticky', top: 44 }}
    >
      <nav className="p-3">
        {GROUPS.map((group) => (
          <div key={group.name} className="mb-4">
            <div className="text-[10px] font-semibold text-text3 uppercase tracking-wider px-2 mb-1">
              {group.name}
            </div>
            {group.items.map((item) => {
              const itemHref = normalize(item.href);
              const aliasMatched = item.aliases?.some((a) =>
                current === normalize(a) || current.startsWith(normalize(a) + '/')
              );
              const active =
                current === itemHref ||
                (itemHref !== '/' && current.startsWith(itemHref + '/')) ||
                aliasMatched;
              const Icon = item.icon;
              return (
                <Link key={item.href} href={item.href}
                  className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
                    active ? 'bg-bg3 text-text font-medium' : 'text-text2 hover:bg-bg3 hover:text-text'
                  }`}>
                  <Icon size={14} />
                  <span className="truncate">{item.label}</span>
                </Link>
              );
            })}
          </div>
        ))}
      </nav>
    </aside>
  );
}
前提条件
Next.js 16 App RouterTailwind CSS v4親 layout.tsx で flex 構造
USE CASES
  • SCALE Finance: 5グループ17項目(売上/顧客/経費/法務/設定)
  • scale-x-app: 7グループ19項目(全体/Pilot/制作/運用/分析/集客/設定)

SystemSidebar(左サイドバーナビ)

:LiTarget: 用途

左固定の240px幅サイドバー。グループ別にリンクを並べて、現在地をハイライト。

  • グループタイトル(小文字大文字)
  • 各リンクにアイコン + テキスト
  • 現在のパスをハイライト表示
  • aliasパスにも反応(href が /foo でも、/foo/bar で active 判定)
  • mobile(md未満)は非表示(モバイルは別途ハンバーガー実装)

:LiCode: コード(コピペ用・骨格)

'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { LayoutDashboard, Settings /* etc */ } from 'lucide-react';
import type { ComponentType, SVGProps } from 'react';

type IconType = ComponentType<SVGProps<SVGSVGElement> & { size?: number | string }>;
type Item = { label: string; href: string; icon: IconType; aliases?: string[] };
type Group = { name: string; items: Item[] };

// ─── ここをカスタマイズ ───
const GROUPS: Group[] = [
  {
    name: '全体',
    items: [
      { label: 'ダッシュボード', href: '/', icon: LayoutDashboard },
    ],
  },
  {
    name: '設定',
    items: [
      { label: 'アカウント', href: '/account', icon: Settings },
    ],
  },
];

function normalize(p: string) {
  if (!p) return '/';
  const trimmed = p.replace(/\/+$/, '');
  return trimmed === '' ? '/' : trimmed;
}

export default function SystemSidebar() {
  const pathname = usePathname() || '/';
  const current = normalize(pathname);

  return (
    <aside
      className="hidden md:flex md:flex-col w-[240px] shrink-0 bg-bg2 border-r border-border overflow-y-auto"
      style={{ height: 'calc(100vh - 44px)', position: 'sticky', top: 44 }}
    >
      <nav className="p-3">
        {GROUPS.map((group) => (
          <div key={group.name} className="mb-4">
            <div className="text-[10px] font-semibold text-text3 uppercase tracking-wider px-2 mb-1">
              {group.name}
            </div>
            {group.items.map((item) => {
              const itemHref = normalize(item.href);
              const aliasMatched = item.aliases?.some((a) =>
                current === normalize(a) || current.startsWith(normalize(a) + '/')
              );
              const active =
                current === itemHref ||
                (itemHref !== '/' && current.startsWith(itemHref + '/')) ||
                aliasMatched;
              const Icon = item.icon;
              return (
                <Link key={item.href} href={item.href}
                  className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
                    active ? 'bg-bg3 text-text font-medium' : 'text-text2 hover:bg-bg3 hover:text-text'
                  }`}>
                  <Icon size={14} />
                  <span className="truncate">{item.label}</span>
                </Link>
              );
            })}
          </div>
        ))}
      </nav>
    </aside>
  );
}

:LiHandPointer: 使用例(layout.tsx に組み込み)

import SystemSidebar from '@/components/layout/SystemSidebar';

export default function RootLayout({ children }) {
  return (
    <html lang="ja">
      <body className="min-h-screen bg-bg text-text">
        <header className="sticky top-0 z-30 h-11 bg-bg2 border-b border-border px-4 flex items-center justify-between">
          {/* ヘッダー内容 */}
        </header>
        <div className="flex">
          <SystemSidebar />
          <main className="flex-1 min-w-0 p-4 md:p-6">{children}</main>
        </div>
      </body>
    </html>
  );
}

:LiAlertCircle: 注意事項

  • header が固定 44px 前提(変える場合は SystemSidebar の top: 44 も合わせる)
  • 'use client' 必須(usePathname のため)
  • Next.js 16 App Router 前提(pages router では別途実装が必要)
  • aliases に複数パスを入れると、サブパス遷移時もハイライト維持できる

:LiLightbulb: 拡張アイデア

  • グループの折りたたみ(useState で open/close)
  • 検索ボックス(filter で items 絞り込み)
  • お気に入りピン留め
  • mobile ドロワー(md未満で表示・ハンバーガー)