DELOGs
Shadcn/uiで作るログイン後の管理画面レイアウト

Shadcn/uiで作るログイン後の管理画面レイアウト

Shadcn/uiで簡単に管理画面UIを構築。共通ヘッダ、サイドメニューなどの基本レイアウトを作成

初回公開日

最終更新日

0. ログイン画面ができたら次は…

前回の記事 「Shadcn/uiで作るログイン画面」 では、Shadcn/uiを使ってログイン画面のUIを作成しました。
ログインフォームができた時点で、次に必要になるのは「ログイン後に表示される画面」です。ということで今回は、ログイン後の管理画面として使うレイアウトを作っていきます。
とはいえ、レイアウトをゼロから組むのは大変です。 Shadcn/ui には 「Shadcn公式サイト:便利なテンプレート集」 が用意されています。これが、かなりできがいいというか、特に管理画面関連は十分という内容なのです。
▼Shadcn公式のテンプレート集
Shadcnのテンプレート画面
今回は、上掲のURLから 「sidebar-07」 を選んで組み込むことにしました。これは、下記のように各メニューにアイコンが付いて、サブメニューが展開されるものです。
Shadcnのテンプレート画面から選んだテンプレート
この構成をベースに、今後のユーザー一覧や設定画面などでも再利用できる共通レイアウトを整えていきます。 バックエンドの機能はまだ入れません。ひとまずUIだけ先に作っておく方針です。

前提

本記事は、前回の記事 **「Shadcn/uiで作るログイン画面」 **の続きとなります。 ソースコードも前回記事を踏襲して、追加していきます。
もし、前回記事を読んでいない方は、Githubのリポジトリを用意しています。よろしければご利用ください。
◯利用方法:
zsh
1git clone https://github.com/delogs-jp/login-form-ui.git 2 3cd login-form-ui 4 5# 依存ライブラリ 6npm install 7 8# Playwright ブラウザバイナリ 9npx playwright install --with-deps

1. sidebar-07を使って土台をつくる

ログイン画面の次は、ログイン後の共通レイアウトを作っていきます。 前章で述べたように、今回はShadcn/uiのテンプレート集から「sidebar-07」を使うことにしました。
まずは、「sidebar-07」をインストールします。 プロジェクトディレクトリへ移動してから 作業してください。
zsh
1npx shadcn@latest add sidebar-07
これを実行すると、いくつかの依存パッケージが追加されたうえで、下記の18個のファイルが生成(うち、ログインフォームで利用している2個はスキップ)されます。
zsh
1✔ Created 16 files: 2 - src/app/dashboard/page.tsx 3 - src/components/app-sidebar.tsx 4 - src/components/nav-main.tsx 5 - src/components/nav-projects.tsx 6 - src/components/nav-user.tsx 7 - src/components/team-switcher.tsx 8 - src/components/ui/sidebar.tsx 9 - src/components/ui/separator.tsx 10 - src/components/ui/sheet.tsx 11 - src/components/ui/tooltip.tsx 12 - src/hooks/use-mobile.ts 13 - src/components/ui/skeleton.tsx 14 - src/components/ui/breadcrumb.tsx 15 - src/components/ui/collapsible.tsx 16 - src/components/ui/dropdown-menu.tsx 17 - src/components/ui/avatar.tsx 18ℹ Skipped 2 files: (files might be identical, use --overwrite to overwrite) 19 - src/components/ui/button.tsx 20 - src/components/ui/input.tsx
見ての通り、かなりしっかりした構成が最初から用意されているのがわかります。 これだけで、サイドメニュー/上部ナビ/ユーザーメニューまでだいたい揃います。

/dashboard を開いてみる

コマンド実行後は /dashboard ページが生成されているので、開いてみると、すでに以下のような画面が表示されるはずです。
src/app/dashboard/page.tsxの内容:
tsx
1import { AppSidebar } from "@/components/app-sidebar" 2import { 3 Breadcrumb, 4 BreadcrumbItem, 5 BreadcrumbLink, 6 BreadcrumbList, 7 BreadcrumbPage, 8 BreadcrumbSeparator, 9} from "@/components/ui/breadcrumb" 10import { Separator } from "@/components/ui/separator" 11import { 12 SidebarInset, 13 SidebarProvider, 14 SidebarTrigger, 15} from "@/components/ui/sidebar" 16 17export default function Page() { 18 return ( 19 <SidebarProvider> 20 <AppSidebar /> 21 <SidebarInset> 22 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"> 23 <div className="flex items-center gap-2 px-4"> 24 <SidebarTrigger className="-ml-1" /> 25 <Separator 26 orientation="vertical" 27 className="mr-2 data-[orientation=vertical]:h-4" 28 /> 29 <Breadcrumb> 30 <BreadcrumbList> 31 <BreadcrumbItem className="hidden md:block"> 32 <BreadcrumbLink href="#"> 33 Building Your Application 34 </BreadcrumbLink> 35 </BreadcrumbItem> 36 <BreadcrumbSeparator className="hidden md:block" /> 37 <BreadcrumbItem> 38 <BreadcrumbPage>Data Fetching</BreadcrumbPage> 39 </BreadcrumbItem> 40 </BreadcrumbList> 41 </Breadcrumb> 42 </div> 43 </header> 44 <div className="flex flex-1 flex-col gap-4 p-4 pt-0"> 45 <div className="grid auto-rows-min gap-4 md:grid-cols-3"> 46 <div className="bg-muted/50 aspect-video rounded-xl" /> 47 <div className="bg-muted/50 aspect-video rounded-xl" /> 48 <div className="bg-muted/50 aspect-video rounded-xl" /> 49 </div> 50 <div className="bg-muted/50 min-h-[100vh] flex-1 rounded-xl md:min-h-min" /> 51 </div> 52 </SidebarInset> 53 </SidebarProvider> 54 ) 55}
npm run devを実行してhttp://localhost:3000/dashboardにアクセスして見てください。下記のように、ダークモード対応以外の管理画面フォーマットがすでに作られた状態になっているのが確認できます。
インストール直後のダッシュボード画面
  • サイドバー(メニュー付き)
  • 上部にユーザーアイコン(クリックでドロップダウン)
  • 中央にページコンテンツ(まだ空)
  • ユーザ情報エリア
  • レスポンシブ対応済み
どうでしょうか。この時点で、もう十分な完成形になっています。
次章では、生成された各ファイルの役割(app-sidebar.tsx, nav-main.tsx, など)を解説しながら、再利用のしやすさや削れる部分も見ていく予定です。
また、そのあとに 「ダークモード切替UI」 を導入する章を追加して、ThemeToggle の実装にも触れていきます。

2. 各パーツの構成をざっと見ておく

sidebar-07を追加すると、けっこう多くのコンポーネントが生成されます。実際に使うのは一部ですが、ここで一度構成をざっと見ておきます。
生成された中から、特によく使う(or 編集する)ファイルは以下です。

全体のレイアウト構成に関わるファイル

ファイル内容
src/app/dashboard/page.tsxダッシュボードのトップページ(最初に表示されるページ)
src/components/app-sidebar.tsxサイドバー全体のラッパー。nav-main.tsxなどを内包
src/components/nav-main.tsxメインナビゲーション(Dashboard, Usersなど)
src/components/nav-user.tsx右上のユーザーメニュー(ログアウトなど)

モバイル表示や拡張用の補助コンポーネント

ファイル内容
src/hooks/use-mobile.tsビューポートの幅を見てモバイル表示かどうかを判定するカスタムフック
src/components/ui/sheet.tsxモバイル用のスライドオーバーメニューに使う部品
src/components/ui/separator.tsx区切り線(メニュー間の仕切り)などで使用

ユーザーやチーム切り替え関連(今回は省略可能)

ファイルコメント
src/components/nav-projects.tsxプロジェクト切り替えUI。今回は不要なので削除してOK
src/components/team-switcher.tsxヘッダの最上部のチーム切り替え。こちらも今回は使わないですが、1チームとしてロゴ配置に利用します
今回は「管理画面の共通レイアウト」を作るのが目的なので、上記の中でも使わない部分(プロジェクト切り替え、チーム切り替えなど)は削除していきます。
次の章では、これらのコンポーネントを少し調整しながら、仮のユーザー情報を渡して表示確認をしていきます。

3. 仮データを分離して、見通しを良くする

sidebar-07 を追加すると、src/components/app-sidebar.tsx にけっこうな量の仮データが含まれた状態になります。 usernavMainteamsprojectsなどがすべて1ファイルに詰め込まれていて、正直なところかなり見づらいです。
tsx
1// src/components/app-sidebar.tsx(初期状態) 2const data = { 3 user: { ... }, 4 navMain: [ ... ], 5 teams: [ ... ], 6 projects: [ ... ], 7}
でも実際のところ、これらのデータはそれぞれ 使うコンポーネントが完全に分かれています。 それなら、用途ごとにファイルを分けておいたほうが、後からの差し替えやメンテナンスもしやすくなります。

必要なものだけ、個別ファイルで管理

そこで今回は、仮データを以下のように切り出しました(projectsは今回は使わないです):
データ用途ファイル型も一緒に定義
ユーザー情報lib/mock-user.tsUser
メインメニューlib/mock-nav-main.tsNavItem
チーム情報(今回は1チームだけ)lib/mock-team.tsTeam
上記のファイルで、 「型とデータをセットで定義してエクスポートする」 方式にします。

例:mock-user.ts

ts
1// lib/mock-user.ts 2 3export type User = { 4 name: string; 5 email: string; 6 avatar: string; 7}; 8 9export const mockUser: User = { 10 name: "山田 太郎", 11 email: "yamada@example.com", 12 avatar: "/user-avatar.png", 13};
このように、データの構造と中身をセットで管理しておくと、後から User 型だけをUI側で再利用することができます。
💡 avatarで指定する画像は、プロジェクト直下の public/user-avatar.png などに配置しておけば、/user-avatar.png のようにそのまま指定できます。
同様に、lib/mock-nav-main.tslib/mock-teams.tsを作成します。

例:mock-nav-main.ts

これはサイドメニューの各項目に該当する部分です。メニュー項目はプロジェクト管理を想定して下記のようにしてみました。
ts : mock-nav-main.ts
1import { 2 BookOpen, 3 Settings2, 4 SquareTerminal, 5 type LucideIcon, 6} from "lucide-react"; 7 8export type NavItem = { 9 title: string; 10 url: string; 11 icon?: LucideIcon; 12 isActive?: boolean; 13 items?: { 14 title: string; 15 url: string; 16 }[]; 17}[]; 18 19export const mockNavMain: NavItem = [ 20 { 21 title: "ダッシュボード", 22 url: "/dashboard", 23 icon: SquareTerminal, 24 isActive: true, 25 items: [ 26 { 27 title: "全体進捗", 28 url: "#", 29 }, 30 { 31 title: "Myプロジェクト", 32 url: "#", 33 }, 34 { 35 title: "Myタスク", 36 url: "#", 37 }, 38 ], 39 }, 40 { 41 title: "ドキュメント", 42 url: "#", 43 icon: BookOpen, 44 items: [ 45 { title: "チュートリアル", url: "#" }, 46 { title: "更新履歴", url: "#" }, 47 ], 48 }, 49 { 50 title: "設定", 51 url: "#", 52 icon: Settings2, 53 items: [ 54 { 55 title: "プロジェクト管理", 56 url: "#", 57 }, 58 { 59 title: "マスタ管理", 60 url: "#", 61 }, 62 { 63 title: "ユーザ管理", 64 url: "#", 65 }, 66 ], 67 }, 68];
上記のように、ルシードアイコンもこちらで定義することになります。

例:lib/mock-team.ts

ここは少し項目名も変更しています。1つだけの情報にして、下記のようにしています。
ts
1export type Team = { 2 name: string; 3 icon: string; 4 lead: string; 5}; 6 7export const mockTeam: Team = { 8 name: "DELOGs", 9 icon: "/icon-64.png", 10 lead: "Demo Dashboard", 11};

UIコンポーネントでの使い方例

詳細は次章で実施します。ここでは例として流れだけです。
たとえば nav-user.tsx では、データ本体は使わずに、型だけを読み込んでいます:
tsx
1// src/components/nav-user.tsx 2 3import type { User } from "@/lib/mock-user" 4 5export function NavUser({ user }: { user: User }) { 6 ... 7}
UI側では「型」だけを参照し、実際の値は app-sidebar.tsxmockUser を渡すようにしています。
app-sidebar.tsxの方は下記のようになります:
tsx
1import { mockUser } from "@/lib/mock-user" 2import { mockNavMain } from "@/lib/mock-nav-main" 3import { mockProjects } from "@/lib/mock-projects" 4import { mockTeams } from "@/lib/mock-teams" 5 6... 7 8<TeamSwitcher teams={mockTeams} /> 9<NavMain items={mockNavMain} /> 10<NavProjects projects={mockProjects} /> 11<NavUser user={mockUser} />
仮データの中身は lib/ 側で管理し、app-sidebar.tsx 側は UIに必要なデータだけを読み込む構成になります。

将来的な実データへの差し替えも簡単

この構成にしておけば、実装が進んだ段階で下記のように切り替えることが可能です:
tsx
1// 今は mock 2import { mockUser } from "@/lib/mock-user" 3 4// 将来は API などに差し替え 5const user: User = await fetchCurrentUser()
User 型は共通なので、UI側のコードに手を加える必要がないというのが大きな利点です。
これで仮データの扱いがシンプルになり、UI構築を進めるうえでも見通しがぐっと良くなります。
次の章では、この構成を活かして /dashboard ページに共通レイアウトを組み込み、今後、他の管理画面上のページでも使い回せるようなベースを整えていきます。

4. ナビゲーション関連コンポーネントを整える

前章で、usernavMainteam の仮データを lib/ に切り出しました。 これらを受け取る側の UI コンポーネントも、それに合わせて修正していきます。
ポイントは以下の3つです。
1. propsでデータを受け取るようにする
コンポーネントが内部で仮データを持つのではなく、外から渡されたデータを描画します。
2. 型定義を共通化
lib/mock-xxx.ts にある型(UserNavItemTeam)をimportして、propsの型として利用します。
3. チーム切替UIを簡略化
今回は1チームしかない前提なので、team-switcher.tsxnav-team.tsxとして作り直します。

ユーザーメニュー nav-user.tsxの修正

tsx
1// src/components/nav-user.tsx 2"use client"; 3 4import { 5 Bell, 6 ChevronsUpDown, 7 KeyRound, 8 LogOut, 9 User as UserIcon, 10} from "lucide-react"; 11 12import type { User } from "@/lib/mock-user"; 13import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 14import { 15 DropdownMenu, 16 DropdownMenuContent, 17 DropdownMenuGroup, 18 DropdownMenuItem, 19 DropdownMenuLabel, 20 DropdownMenuSeparator, 21 DropdownMenuTrigger, 22} from "@/components/ui/dropdown-menu"; 23import { 24 SidebarMenu, 25 SidebarMenuButton, 26 SidebarMenuItem, 27 useSidebar, 28} from "@/components/ui/sidebar"; 29 30export function NavUser({ user }: { user: User }) { 31 const { isMobile } = useSidebar(); 32 33 return ( 34 <SidebarMenu> 35 <SidebarMenuItem> 36 <DropdownMenu> 37 <DropdownMenuTrigger asChild> 38 <SidebarMenuButton 39 size="lg" 40 className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" 41 aria-label="ユーザーメニューを開く" 42 > 43 <Avatar className="h-8 w-8 rounded-lg"> 44 {/* 画像は next/image でもOKだが AvatarImage で十分 */} 45 <AvatarImage src={user.avatar} alt={user.name} /> 46 <AvatarFallback className="rounded-lg"> 47 {user.name.slice(0, 1)} 48 </AvatarFallback> 49 </Avatar> 50 <div className="grid flex-1 text-left text-sm leading-tight"> 51 <span className="truncate font-medium">{user.name}</span> 52 <span className="text-muted-foreground truncate text-xs"> 53 {user.email} 54 </span> 55 </div> 56 <ChevronsUpDown className="ml-auto size-4" /> 57 </SidebarMenuButton> 58 </DropdownMenuTrigger> 59 60 <DropdownMenuContent 61 className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg" 62 side={isMobile ? "bottom" : "right"} 63 align="end" 64 sideOffset={4} 65 > 66 {/* ヘッダー(ユーザー情報の再掲) */} 67 <DropdownMenuLabel className="p-0 font-normal"> 68 <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> 69 <Avatar className="h-8 w-8 rounded-lg"> 70 <AvatarImage src={user.avatar} alt={user.name} /> 71 <AvatarFallback className="rounded-lg"> 72 {user.name.slice(0, 1)} 73 </AvatarFallback> 74 </Avatar> 75 <div className="grid flex-1 text-left text-sm leading-tight"> 76 <span className="truncate font-medium">{user.name}</span> 77 <span className="text-muted-foreground truncate text-xs"> 78 {user.email} 79 </span> 80 </div> 81 </div> 82 </DropdownMenuLabel> 83 84 <DropdownMenuSeparator /> 85 86 <DropdownMenuGroup> 87 <DropdownMenuItem asChild> 88 {/* TODO: /account などに差し替え */} 89 <a href="#" className="flex items-center gap-2"> 90 <UserIcon className="size-4" /> 91 ユーザー情報確認 92 </a> 93 </DropdownMenuItem> 94 95 <DropdownMenuItem asChild> 96 {/* TODO: /account/password などに差し替え */} 97 <a href="#" className="flex items-center gap-2"> 98 <KeyRound className="size-4" /> 99 パスワード変更 100 </a> 101 </DropdownMenuItem> 102 103 <DropdownMenuItem asChild> 104 {/* TODO: /notifications に差し替え */} 105 <a href="#" className="flex items-center gap-2"> 106 <Bell className="size-4" /> 107 通知 108 </a> 109 </DropdownMenuItem> 110 </DropdownMenuGroup> 111 112 <DropdownMenuSeparator /> 113 114 <DropdownMenuItem asChild> 115 {/* TODO: /api/logout などにPOST。UIだけなら href="#" でOK */} 116 <a href="#" className="text-destructive flex items-center gap-2"> 117 <LogOut className="size-4" /> 118 ログアウト 119 </a> 120 </DropdownMenuItem> 121 </DropdownMenuContent> 122 </DropdownMenu> 123 </SidebarMenuItem> 124 </SidebarMenu> 125 ); 126}

💡変更ポイント

  • 英語ラベル(Upgrade to Pro / Account / Billing…)を削除し、4項目に絞って日本語化。
  • User 型は @/lib/mock-user から import。any は未使用。
  • 見た目のトーンは shadcn のまま、アクセシビリティ用の aria-labelmuted なテキストを追加。
  • リンクは暫定で href="#"。後で /account, /account/password, /notifications, POST /api/logout へ差し替え想定。

メインナビ nav-main.tsxの修正

tsx
1// src/components/nav-main.tsx 2"use client"; 3 4import Link from "next/link"; 5import { ChevronRight } from "lucide-react"; 6 7import type { NavItem } from "@/lib/mock-nav-main"; 8import { 9 Collapsible, 10 CollapsibleContent, 11 CollapsibleTrigger, 12} from "@/components/ui/collapsible"; 13import { 14 SidebarGroup, 15 SidebarGroupLabel, 16 SidebarMenu, 17 SidebarMenuButton, 18 SidebarMenuItem, 19 SidebarMenuSub, 20 SidebarMenuSubButton, 21 SidebarMenuSubItem, 22} from "@/components/ui/sidebar"; 23 24type Props = { items: NavItem }; 25 26export function NavMain({ items }: Props) { 27 return ( 28 <SidebarGroup> 29 <SidebarGroupLabel>メニュー</SidebarGroupLabel> 30 31 <SidebarMenu> 32 {items.map((item) => { 33 const hasChildren = !!item.items?.length; 34 35 // 子がない場合は Collapsible を使わずにそのままリンク 36 if (!hasChildren) { 37 return ( 38 <SidebarMenuItem key={item.title}> 39 <SidebarMenuButton 40 asChild 41 className={item.isActive ? "bg-muted font-semibold" : ""} 42 tooltip={item.title} 43 aria-current={item.isActive ? "page" : undefined} 44 > 45 <Link href={item.url}> 46 {item.icon && <item.icon className="size-4" />} 47 <span>{item.title}</span> 48 </Link> 49 </SidebarMenuButton> 50 </SidebarMenuItem> 51 ); 52 } 53 54 // 子がある場合は Collapsible(開閉可) 55 return ( 56 <Collapsible 57 key={item.title} 58 asChild 59 defaultOpen={item.isActive} 60 className="group/collapsible" 61 > 62 <SidebarMenuItem> 63 <CollapsibleTrigger asChild> 64 <SidebarMenuButton 65 tooltip={item.title} 66 className={item.isActive ? "bg-muted font-semibold" : ""} 67 aria-expanded={item.isActive ? true : undefined} 68 > 69 {item.icon && <item.icon className="size-4" />} 70 <span>{item.title}</span> 71 <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" /> 72 </SidebarMenuButton> 73 </CollapsibleTrigger> 74 75 <CollapsibleContent> 76 <SidebarMenuSub> 77 {item.items?.map((sub) => ( 78 <SidebarMenuSubItem key={sub.title}> 79 <SidebarMenuSubButton asChild> 80 <Link href={sub.url}> 81 <span>{sub.title}</span> 82 </Link> 83 </SidebarMenuSubButton> 84 </SidebarMenuSubItem> 85 ))} 86 </SidebarMenuSub> 87 </CollapsibleContent> 88 </SidebarMenuItem> 89 </Collapsible> 90 ); 91 })} 92 </SidebarMenu> 93 </SidebarGroup> 94 ); 95}

💡変更ポイント

  • type Props = { items: NavItem }(※mock-nav-main.ts の「配列型エイリアス」に合わせています)
  • サブメニューなしは Link 直、サブメニューありは Collapsible で開閉可能に
  • isActive のとき bg-muted font-semibold を付与
  • すべて next/link に統一、aria-current/aria-expanded を適宜付与
  • ラベルを 「Platform」→「メニュー」 に変更

nav-team.tsx の新規作成

sidebar-07 では、チーム切り替え用に team-switcher.tsx というコンポーネントが用意されていました。 ただ、今回の構成では 「チームは1つのみ」 を前提としており、切り替え機能自体は不要です。
そこで、よりシンプルにチーム情報を表示する専用コンポーネントとして nav-team.tsx を新規作成します。
  • 複数チームのドロップダウンなどは省略
  • lib/mock-team.tsTeam 型に合わせて props を受け取る
  • チーム名とリード文を表示
  • アイコンは public/icon-64.png を使用(例)
tsx
1// src/components/nav-team.tsx 2"use client"; 3 4import Image from "next/image"; 5 6import type { Team } from "@/lib/mock-team"; 7import { 8 SidebarMenu, 9 SidebarMenuButton, 10 SidebarMenuItem, 11} from "@/components/ui/sidebar"; 12 13export function NavTeam({ team }: { team: Team }) { 14 return ( 15 <SidebarMenu> 16 <SidebarMenuItem> 17 <SidebarMenuButton 18 size="lg" 19 className="cursor-default" 20 aria-label="チーム情報" 21 > 22 <div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg"> 23 <Image 24 src={team.icon} 25 alt={team.name} 26 width={32} 27 height={32} 28 priority 29 className="object-contain" 30 /> 31 </div> 32 <div className="grid flex-1 text-left text-sm leading-tight"> 33 <span className="truncate font-medium">{team.name}</span> 34 <span className="text-muted-foreground truncate text-xs"> 35 {team.lead} 36 </span> 37 </div> 38 </SidebarMenuButton> 39 </SidebarMenuItem> 40 </SidebarMenu> 41 ); 42}

💡補足ポイント

  • 見た目は SidebarMenuButton を再利用しており、クリックしても何も起きません(cursor-defaultで無効化)
  • team.iconpublic/ 配下に配置した画像を指定しておけば、/icon-64.png のようにそのまま読み込めます
  • SidebarMenu の一部として統一感ある見た目になります
次章では、これらの共通コンポーネントを app-sidebar.tsx に読み込んで表示する構成を整えていきます。

5. Sidebar全体の構成を整理する ─ 仮データ脱却と不要UIの削除

前章までで、仮データをlib/配下に分離し、それぞれのUIコンポーネントも props 経由で受け取るように整理しました。
ここでは、サイドバー全体を司る AppSidebar コンポーネント(src/components/app-sidebar.tsx)を整理して、共通レイアウトとしてのベースを完成させます。

修正前の構成(テンプレート状態)

初期状態の AppSidebar は、下記のように全データが内部のdataオブジェクトに直書きされており、サイドバー内の構成もかなり多機能に設計されています。
tsx
1// 修正前の構成イメージ 2<Sidebar> 3 <SidebarHeader> 4 <TeamSwitcher teams={data.teams} /> 5 </SidebarHeader> 6 <SidebarContent> 7 <NavMain items={data.navMain} /> 8 <NavProjects projects={data.projects} /> 9 </SidebarContent> 10 <SidebarFooter> 11 <NavUser user={data.user} /> 12 </SidebarFooter> 13 <SidebarRail /> 14</Sidebar>
この構成には以下のような課題があります:
課題内容
data の肥大化全ての仮データが1ファイルに詰め込まれていて見通しが悪い
使わないUIの混在今回の構成ではチーム切り替え・プロジェクト切り替えは不要

今回の構成方針

下記のような シンプルかつ拡張性ある構成 へ移行します:
tsx
1<Sidebar collapsible="icon"> 2 <SidebarHeader> 3 <NavTeam team={mockTeam} /> 4 </SidebarHeader> 5 <SidebarContent> 6 <NavMain items={mockNavMain} /> 7 </SidebarContent> 8 <SidebarFooter> 9 <ModeToggle /> 10 <NavUser user={mockUser} /> 11 </SidebarFooter> 12 <SidebarRail /> 13</Sidebar>
  • 仮データの参照元はすべて lib/ に切り出したものを使用
  • チーム表示は NavTeam に置き換え(切り替え機能は不要)
  • NavProjects は削除

修正後の app-sidebar.tsx

tsx
1// src/components/app-sidebar.tsx 2"use client"; 3 4import * as React from "react"; 5 6import { NavMain } from "@/components/nav-main"; 7import { NavUser } from "@/components/nav-user"; 8import { NavTeam } from "@/components/nav-team"; 9 10import { 11 Sidebar, 12 SidebarContent, 13 SidebarFooter, 14 SidebarHeader, 15 SidebarRail, 16} from "@/components/ui/sidebar"; 17 18import { mockTeam } from "@/lib/mock-team"; 19import { mockNavMain } from "@/lib/mock-nav-main"; 20import { mockUser } from "@/lib/mock-user"; 21 22export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { 23 return ( 24 <Sidebar collapsible="icon" {...props}> 25 <SidebarHeader> 26 <NavTeam team={mockTeam} /> 27 </SidebarHeader> 28 <SidebarContent> 29 <NavMain items={mockNavMain} /> 30 </SidebarContent> 31 <SidebarFooter> 32 <NavUser user={mockUser} /> 33 </SidebarFooter> 34 <SidebarRail /> 35 </Sidebar> 36 ); 37}

変更点のまとめ

項目変更内容
✅ データ分離dataオブジェクトを完全に削除し、すべて lib/ から import
✅ コンポーネント整理TeamSwitcher, NavProjects, SidebarRail を削除
✅ 構成簡略化最小限の構成(チーム表示・メニュー・ユーザーメニュー)に絞る
✅ 型安全化mock-user.ts, mock-team.ts, mock-nav-main.ts にて型定義付きで管理
ここまでできたら、npm run devでテスト起動してみてください。下記にようになります。
管理画面レイアウトが完成した様子

今後の展開に向けて

今回の修正によって、AppSidebar は以下のような状態になりました:
  • 全ての表示データが外部管理になり、UIコンポーネントは props を受け取って描画する構造
  • 1チーム前提/1プロジェクト前提という現時点での実装に合わせて最適化
  • 今後、実データ化する際もUIに変更は不要mock → APIやサーバアクションへの切替のみ)
この共通レイアウトは、今後のユーザー管理画面や設定画面、ダッシュボードの各種ページでも再利用可能です。
次章では、ここに ダークモード切替UIModeToggle) を導入して、ナイトモードへの切替に対応する実装を行っていきます。

6. ダークモード切り替えUIの導入

ここまでで、共通レイアウトとしての AppSidebar を完成させました。次は、現代的なUIに欠かせない? 「ダークモードの切り替え機能」を実装していきます。これは不要な方は飛ばしてください。
今回は、Shadcn/uiの公式ドキュメントでも紹介されている next-themes を使って、テーマ切り替えが可能な構成を整えます。

next-themes のインストール

まずは、テーマ切り替えを扱うためのライブラリ next-themes をインストールします。
zsh
1npm install next-themes
このパッケージは、localStorage にテーマ設定を保持しつつ、<html class="dark"> のようなクラス切り替えを自動で行ってくれる便利なライブラリです。

ThemeProvider の追加

App Routerに合わせた ThemeProvider を自前で定義します。
下記のような src/components/theme-provider.tsx を作成してください:
tsx
1"use client"; 2 3import * as React from "react"; 4import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 6export function ThemeProvider({ 7 children, 8 ...props 9}: React.ComponentProps<typeof NextThemesProvider>) { 10 return <NextThemesProvider {...props}>{children}</NextThemesProvider>; 11}
この ThemeProvider を、アプリ全体のルートで使えるように src/app/layout.tsx に組み込みます。
tsx
1// src/app/layout.tsx 2import type { Metadata } from "next"; 3import { Noto_Sans_JP } from "next/font/google"; 4import "./globals.css"; 5import { ThemeProvider } from "@/components/theme-provider"; 6 7const notoSansJP = Noto_Sans_JP({ 8 subsets: ["latin"], 9 variable: "--font-noto-sans-jp", 10}); 11 12export const metadata: Metadata = { 13 title: "管理画面レイアウト【DELOGs】", 14 description: "shadcn/uiを使用した管理画面レイアウト", 15}; 16 17export default function RootLayout({ 18 children, 19}: Readonly<{ 20 children: React.ReactNode; 21}>) { 22 return ( 23 <html lang="ja" suppressHydrationWarning> 24 <body className={`${notoSansJP.variable} font-sans antialiased`}> 25 <ThemeProvider 26 attribute="class" 27 defaultTheme="system" 28 enableSystem 29 disableTransitionOnChange 30 > 31 {children} 32 </ThemeProvider> 33 </body> 34 </html> 35 ); 36}
💡 suppressHydrationWarning ってなに?
next-themes はクライアントでテーマ状態を動的に変えるため、サーバ側とクライアント側で className(例:light or dark)が一致しないタイミングが起き得ます。
これによりNext.jsが警告(hydration mismatch)を出すため、それを抑制するのが suppressHydrationWarning です。
💡 ThemeProvider に渡している各属性は、ダークモードの挙動を細かく制御するための設定です。ひとつずつ解説します。
属性名役割
attribute="class"HTMLの<html>タグに class="dark" を付ける方式でダークモードを制御します。Tailwind CSS の darkMode: "class" 設定と連動しているため、これが必須です。
defaultTheme="system"初期状態では、ユーザーのOSの設定(ライト or ダーク)を自動で使います。個別にテーマを指定しない限り、常にOSの設定に追従します。
enableSystem上記のdefaultTheme="system"が有効に動作するようにします。これがないとOSのテーマ設定が反映されません。
disableTransitionOnChangeテーマ切り替え時に不要なアニメーション(フェードやズームなど)が発生しないようにします。画面のちらつきを防げるため、UXが向上します。

ダークモード切り替えボタンの設置

次に、ユーザーが「ライト/ダーク」を切り替えるためのUIを作ります。
下記のように src/components/mode-toggle.tsx を作成します:
tsx
1// src/components/mode-toggle.tsx 2"use client"; 3 4import * as React from "react"; 5import { Moon, Sun } from "lucide-react"; 6import { useTheme } from "next-themes"; 7import { Button } from "@/components/ui/button"; 8 9type Props = { 10 className?: string; 11}; 12 13export function ModeToggle({ className }: Props) { 14 const { setTheme, resolvedTheme } = useTheme(); 15 const [mounted, setMounted] = React.useState(false); 16 17 // クライアントマウント後にのみ実行(SSRと一致) 18 React.useEffect(() => { 19 setMounted(true); 20 }, []); 21 22 if (!mounted) { 23 return ( 24 <Button 25 variant="ghost" 26 size="icon" 27 className={className} 28 aria-label="テーマ切り替え(読み込み中)" 29 disabled 30 > 31 <Sun className="size-4 animate-pulse opacity-50" /> 32 </Button> 33 ); 34 } 35 36 const isDark = resolvedTheme === "dark"; 37 38 return ( 39 <Button 40 variant="ghost" 41 size="icon" 42 className={className} 43 aria-label="ダークモード切り替え" 44 onClick={() => setTheme(isDark ? "light" : "dark")} 45 > 46 {isDark ? <Sun className="size-4" /> : <Moon className="size-4" />} 47 <span className="sr-only">Toggle theme</span> 48 </Button> 49 ); 50}

💡 実装ポイント

Shadcn/UI公式のドキュメントだと、ドロップダウンメニューを使ったものになっているので、改変しています。
項目内容
useTheme()next-themes からテーマの取得/変更機能を提供。
setTheme()でテーマ変更、resolvedThemeで現在の実際のテーマを取得。
useEffect()クライアントマウント後に mounted = true をセット。
SSRとのテーマ不一致(Hydrationエラー)を防ぐための措置。
!mounted時の描画初期マウント前は Sun アイコンをアニメーション付きで表示。
読み込み中であることを視覚的に伝える。
テーマ状態の判定resolvedTheme === "dark" なら isDark = true
状態に応じて SunMoon アイコンを切り替え。
ボタンの切り替え動作現在がダークモードならライトへ、ライトならダークへ切り替える挙動に設定。
className対応props.className を受け取って <Button> に渡すことで、呼び出し側からのレイアウト調整が可能に。

AppSidebar に組み込む

作成した ModeToggleAppSidebar.tsx のフッター内に組み込みます。
tsx
1"use client"; 2 3import * as React from "react"; 4 5import { ModeToggle } from "@/components/mode-toggle"; //mode-toggle.tsxの読み込みを追加 6import { NavMain } from "@/components/nav-main"; 7 8・・・省略・・・ 9 10 <SidebarFooter> 11 <ModeToggle className="ml-auto" /> {/* トグルボタンを右寄せで配置 */} 12 <NavUser user={mockUser} /> 13 </SidebarFooter> 14 15・・・省略・・・ 16
実際の見た目としては、ユーザーアイコンの上に月(または太陽)マークが追加され、それをクリックすることで即時テーマが切り替わります。

Tailwind CSS 側の設定確認

src/app/globals.css において、.darkクラス が設定されていることを確認します。
css
1.dark { 2 --background: oklch(0.145 0 0); 3 --foreground: oklch(0.985 0 0); 4 --card: oklch(0.205 0 0); 5 --card-foreground: oklch(0.985 0 0); 6 --popover: oklch(0.205 0 0); 7 --popover-foreground: oklch(0.985 0 0); 8 --primary: oklch(0.922 0 0); 9 --primary-foreground: oklch(0.205 0 0); 10 --secondary: oklch(0.269 0 0); 11 --secondary-foreground: oklch(0.985 0 0); 12 --muted: oklch(0.269 0 0); 13 --muted-foreground: oklch(0.708 0 0); 14 --accent: oklch(0.269 0 0); 15 --accent-foreground: oklch(0.985 0 0); 16 --destructive: oklch(0.704 0.191 22.216); 17 --border: oklch(1 0 0 / 10%); 18 --input: oklch(1 0 0 / 15%); 19 --ring: oklch(0.556 0 0); 20 --chart-1: oklch(0.488 0.243 264.376); 21 --chart-2: oklch(0.696 0.17 162.48); 22 --chart-3: oklch(0.769 0.188 70.08); 23 --chart-4: oklch(0.627 0.265 303.9); 24 --chart-5: oklch(0.645 0.246 16.439); 25 --sidebar: oklch(0.205 0 0); 26 --sidebar-foreground: oklch(0.985 0 0); 27 --sidebar-primary: oklch(0.488 0.243 264.376); 28 --sidebar-primary-foreground: oklch(0.985 0 0); 29 --sidebar-accent: oklch(0.269 0 0); 30 --sidebar-accent-foreground: oklch(0.985 0 0); 31 --sidebar-border: oklch(1 0 0 / 10%); 32 --sidebar-ring: oklch(0.556 0 0); 33}
Shadcn/uiをインストールすると自動で挿入されるエリアになります。念のため確認しておきましょう。上記を調整することで、ダークモードの配色の調整が可能です。
ここまでの作業で、管理画面レイアウトに「ライト/ダーク切り替えUI」が追加されました。
  • テーマは localStorage に保存され、次回以降も自動で反映されます。
  • サーバとのレンダリング差異は suppressHydrationWarning で回避
  • Tailwind + Shadcnの class="dark" 構成で自動的に切り替わります
実際にnpm run devlocalhost:3000/dashboardにアクセスすると下記のようにダークモードが体験できます。
管理画面ダークモード切り替えが完成した様子

(補足)ログイン画面のロゴ切り替え

最後に補足になります。 私が用意しているログイン画面のロゴ画像が文字色黒の画像なので、ダークモードだと見えなくなります。このようなケースで表示モードによって、ロゴ画像を切り替える方法についても記載しておきます。
src/app/page.tsxがログイン画面に当たりますので、これを修正します。
tsx
1・・・省略・・・ 2 <main className="flex min-h-svh w-full items-center justify-center bg-gray-800 p-6 md:p-10 dark:bg-neutral-800"> 3 <Card className="w-full max-w-md"> 4 <CardHeader> 5 <CardTitle className="flex justify-center"> 6 {/* light用ロゴ(=ダークモード時に非表示) */} 7 <Image 8 src="/logo.svg" 9 alt="サイトロゴ" 10 width={160} 11 height={40} 12 priority 13 className="h-[40px] w-[160px] dark:hidden" 14 /> 15 16 {/* dark用ロゴ(=ダークモード時に表示) */} 17 <Image 18 src="/logo-d.svg" 19 alt="サイトロゴ(ダーク)" 20 width={160} 21 height={40} 22 priority 23 className="hidden h-[40px] w-[160px] dark:block" 24 /> 25 </CardTitle> 26 </CardHeader> 27 <CardContent> 28 <LoginForm /> 29 </CardContent> 30 </Card> 31 </main> 32・・・省略・・・
上記のような感じです。TailwindCSSのdark:でダークモードのときのCSS指定が可能です。ここでは<main>タグの背景色とロゴ画像の表示切り替えをしました。 ロゴはダークモード用に白文字の画像を用意して切り替えています。page.tsxはサーバサイドレンダリングで統一したいので、上記のようにしています。
更に補足ですが、クライアントサイドのレンダリング時はもっときれいに:
tsx
1"use client"; 2 3import Image from "next/image"; 4import { useTheme } from "next-themes"; 5import { 6 Card, 7 CardContent, 8 CardHeader, 9 CardTitle, 10} from "@/components/ui/card"; 11import LoginForm from "@/components/LoginForm"; 12 13export default function Page() { 14 const { resolvedTheme } = useTheme(); 15 16 const logoSrc = 17 resolvedTheme === "dark" ? "/logo-dark.svg" : "/logo.svg"; 18 19 return ( 20 <main className="flex min-h-svh w-full items-center justify-center bg-gray-800 p-6 md:p-10"> 21 <Card className="w-full max-w-md"> 22 <CardHeader> 23 <CardTitle className="flex justify-center"> 24 <Image 25 src={logoSrc} 26 alt="サイトロゴ" 27 width={160} 28 height={40} 29 priority 30 className="h-[40px] w-[160px]" 31 /> 32 </CardTitle> 33 </CardHeader> 34 <CardContent> 35 <LoginForm /> 36 </CardContent> 37 </Card> 38 </main> 39 ); 40}
のような書き方も可能です。useTheme()を利用して、表示モードを判定するやり方です。
以上で、管理画面レイアウトは完了です。Shadcn/UIのおかげでかなり簡単に完了しました。
次回以降の記事では、ユーザ登録やユーザ一覧などのUIについて実践した内容を掲載予定です。

参考文献

  • Shadcn/ui - Templates(公式)
    今回のレイアウトテンプレート sidebar-07 を取得した元ソースです。実装における命名やUI構成もこちらに準拠しています。
  • Shadcn/ui - Dark Mode(Next.js)
    next-themes を用いたダークモード切り替え構成を公式が案内しているページです。ThemeProvider の構成や注意点について記述があります。
  • next-themes(GitHub)
    テーマ切り替えの中核ライブラリで、useTheme() の仕様や、SSR時の注意点などが記載されています。
  • Tailwind CSS - Dark Mode
    dark: クラスの使い方や class 方式での切り替え構成についての基本ドキュメントです。
この記事の執筆・編集担当
DE

松本 孝太郎

DELOGs編集部/中年新米プログラマー

ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。