SCALE — Build Lab
UI部品 · REACT COMPONENT

サブナビゲーション

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

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { findSubNavGroup, normalizePath } from '@/lib/subnav-groups';

// すべてのシステム配下ページの上部にこのSubNavが入る(layout.tsxで呼び出し)
// pathname からグループを自動判定し、該当グループのタブを表示する
export default function SubNav() {
  const pathname = usePathname() || '';
  const group = findSubNavGroup(pathname);
  if (!group) return null;
  const current = normalizePath(pathname);

  // グループ内で active になるタブを1つだけ決定(完全一致優先、なければ最長prefix一致)
  const activeIdx = (() => {
    // 1. 完全一致(href または aliases)
    for (let i = 0; i < group.tabs.length; i++) {
      const t = group.tabs[i];
      if (t.href === current) return i;
      if (t.aliases?.includes(current)) return i;
    }
    // 2. prefix一致(システムルート /tasks 等は除外)。最長のものを選ぶ
    const isRoot = (h: string) => h.split('/').filter(Boolean).length < 2;
    let bestIdx = -1;
    let bestLen = -1;
    for (let i = 0; i < group.tabs.length; i++) {
      const t = group.tabs[i];
      if (!isRoot(t.href) && current.startsWith(t.href + '/') && t.href.length > bestLen) {
        bestLen = t.href.length;
        bestIdx = i;
      }
      for (const a of t.aliases || []) {
        if (!isRoot(a) && current.startsWith(a + '/') && a.length > bestLen) {
          bestLen = a.length;
          bestIdx = i;
        }
      }
    }
    return bestIdx;
  })();

  return (
    <div className="sticky -top-4 md:-top-6 z-30 mb-5 -mx-4 md:-mx-6 -mt-4 md:-mt-6 px-4 md:px-6 pt-4 md:pt-6 pb-1 bg-bg/95 backdrop-blur-md">
      <div className="flex items-center gap-2 border-b border-[#222] overflow-x-auto">
        {group.tabs.map((tab, i) => {
          const active = i === activeIdx;
          return (
            <Link
              key={tab.href}
              href={tab.href}
              className={`px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${active ? 'border-blue-500 text-white' : 'border-transparent text-text-muted hover:text-text'}`}
            >
              {tab.label}
            </Link>
          );
        })}
      </div>
    </div>
  );
}

前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • タスク詳細
  • PM内タブ
  • 人事内タブ

サブナビゲーション

:LiTarget: 用途

セクション内の二次ナビ。ピル型タブで切替。

:LiSparkle: 特徴

  • ピル型タブ
  • グルーピング
  • アクティブ判定

:LiCode: 実コード(SCALE Base より自動抽出)

:LiInfo: components/layout/SubNav.tsx の中身そのもの。コピペ即可。

'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { findSubNavGroup, normalizePath } from '@/lib/subnav-groups';

// すべてのシステム配下ページの上部にこのSubNavが入る(layout.tsxで呼び出し)
// pathname からグループを自動判定し、該当グループのタブを表示する
export default function SubNav() {
  const pathname = usePathname() || '';
  const group = findSubNavGroup(pathname);
  if (!group) return null;
  const current = normalizePath(pathname);

  // グループ内で active になるタブを1つだけ決定(完全一致優先、なければ最長prefix一致)
  const activeIdx = (() => {
    // 1. 完全一致(href または aliases)
    for (let i = 0; i < group.tabs.length; i++) {
      const t = group.tabs[i];
      if (t.href === current) return i;
      if (t.aliases?.includes(current)) return i;
    }
    // 2. prefix一致(システムルート /tasks 等は除外)。最長のものを選ぶ
    const isRoot = (h: string) => h.split('/').filter(Boolean).length < 2;
    let bestIdx = -1;
    let bestLen = -1;
    for (let i = 0; i < group.tabs.length; i++) {
      const t = group.tabs[i];
      if (!isRoot(t.href) && current.startsWith(t.href + '/') && t.href.length > bestLen) {
        bestLen = t.href.length;
        bestIdx = i;
      }
      for (const a of t.aliases || []) {
        if (!isRoot(a) && current.startsWith(a + '/') && a.length > bestLen) {
          bestLen = a.length;
          bestIdx = i;
        }
      }
    }
    return bestIdx;
  })();

  return (
    <div className="sticky -top-4 md:-top-6 z-30 mb-5 -mx-4 md:-mx-6 -mt-4 md:-mt-6 px-4 md:px-6 pt-4 md:pt-6 pb-1 bg-bg/95 backdrop-blur-md">
      <div className="flex items-center gap-2 border-b border-[#222] overflow-x-auto">
        {group.tabs.map((tab, i) => {
          const active = i === activeIdx;
          return (
            <Link
              key={tab.href}
              href={tab.href}
              className={`px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${active ? 'border-blue-500 text-white' : 'border-transparent text-text-muted hover:text-text'}`}
            >
              {tab.label}
            </Link>
          );
        })}
      </div>
    </div>
  );
}

:LiFolder: ソースファイルのパス

/Users/oogushiyuuki/Library/CloudStorage/GoogleDrive-y-ogushi@scale-group.co.jp/マイドライブ/AI/scale-base/components/layout/SubNav.tsx

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加