概要
Context と静的プロパティを使った Compound Component パターンで、<Tabs> コンポーネントを実装する。親が状態を持ち、子コンポーネント(Tabs.List / Tabs.Tab / Tabs.Panel)が Context 経由でその状態を共有する。Radix UI や shadcn/ui の内部設計の土台になるパターン。
インストール
# 追加インストールは不要
実装
Tabs コンポーネント
// components/Tabs.tsx
"use client";
import {
createContext,
useContext,
useState,
type ReactNode,
} from "react";
type TabsContextValue = {
activeTab: string;
setActiveTab: (id: string) => void;
};
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Tabs の子コンポーネント内で使用してください");
return ctx;
}
// ルートコンポーネント: 状態を持つ
function TabsRoot({
defaultTab,
children,
}: {
defaultTab: string;
children: ReactNode;
}) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div>{children}</div>
</TabsContext.Provider>
);
}
// タブボタンのリスト
function TabsList({ children }: { children: ReactNode }) {
return (
<div className="flex gap-1 border-b border-gray-200" role="tablist">
{children}
</div>
);
}
// 個別のタブボタン
function TabsTab({
id,
children,
}: {
id: string;
children: ReactNode;
}) {
const { activeTab, setActiveTab } = useTabs();
const isActive = activeTab === id;
return (
<button
role="tab"
aria-selected={isActive}
onClick={() => setActiveTab(id)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
isActive
? "border-b-2 border-blue-600 text-blue-600"
: "text-gray-500 hover:text-gray-800"
}`}
>
{children}
</button>
);
}
// タブに対応するコンテンツ
function TabsPanel({
id,
children,
}: {
id: string;
children: ReactNode;
}) {
const { activeTab } = useTabs();
if (activeTab !== id) return null;
return (
<div role="tabpanel" className="pt-4">
{children}
</div>
);
}
// 静的プロパティとして子コンポーネントを束ねる
export const Tabs = Object.assign(TabsRoot, {
List: TabsList,
Tab: TabsTab,
Panel: TabsPanel,
});
使用例
// app/page.tsx
import { Tabs } from "@/components/Tabs";
export default function Page() {
return (
<main className="mx-auto max-w-xl p-8">
<Tabs defaultTab="overview">
<Tabs.List>
<Tabs.Tab id="overview">概要</Tabs.Tab>
<Tabs.Tab id="details">詳細</Tabs.Tab>
<Tabs.Tab id="reviews">レビュー</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="overview">
<p className="text-gray-600">概要コンテンツ</p>
</Tabs.Panel>
<Tabs.Panel id="details">
<p className="text-gray-600">詳細コンテンツ</p>
</Tabs.Panel>
<Tabs.Panel id="reviews">
<p className="text-gray-600">レビューコンテンツ</p>
</Tabs.Panel>
</Tabs>
</main>
);
}
ポイント
createContextで状態を Context に入れ、親コンポーネントがactiveTabを管理する。子はuseContext経由で状態を読み書きするObject.assign(TabsRoot, { List, Tab, Panel })で子コンポーネントを静的プロパティとして束ねると、<Tabs.Tab>のような名前空間付き API になるuseTabsのカスタムフックでnullチェックを集約し、Provider外で使った場合に明快なエラーを投げる- 状態はルートコンポーネントに閉じており、個別コンポーネントに props を垂れ流さない(prop drilling なし)
- Radix UI や shadcn/ui はこのパターンを応用している。外部ライブラリを理解する土台として実装を把握しておくと役立つ