
Shadcn/uiで作るログイン後の管理画面レイアウト
Shadcn/uiで簡単に管理画面UIを構築。共通ヘッダ、サイドメニューなどの基本レイアウトを作成
初回公開日
最終更新日
0. ログイン画面ができたら次は…
前回の記事 「Shadcn/uiで作るログイン画面」 では、Shadcn/uiを使ってログイン画面のUIを作成しました。
ログインフォームができた時点で、次に必要になるのは「ログイン後に表示される画面」です。ということで今回は、ログイン後の管理画面として使うレイアウトを作っていきます。
とはいえ、レイアウトをゼロから組むのは大変です。
Shadcn/ui には 「Shadcn公式サイト:便利なテンプレート集」 が用意されています。これが、かなりできがいいというか、特に管理画面関連は十分という内容なのです。
▼Shadcn公式のテンプレート集

今回は、上掲のURLから 「sidebar-07」 を選んで組み込むことにしました。これは、下記のように各メニューにアイコンが付いて、サブメニューが展開されるものです。

この構成をベースに、今後のユーザー一覧や設定画面などでも再利用できる共通レイアウトを整えていきます。
バックエンドの機能はまだ入れません。ひとまず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
にけっこうな量の仮データが含まれた状態になります。
user
・navMain
・teams
・projects
などがすべて1ファイルに詰め込まれていて、正直なところかなり見づらいです。tsx
1// src/components/app-sidebar.tsx(初期状態)
2const data = {
3 user: { ... },
4 navMain: [ ... ],
5 teams: [ ... ],
6 projects: [ ... ],
7}
でも実際のところ、これらのデータはそれぞれ 使うコンポーネントが完全に分かれています。
それなら、用途ごとにファイルを分けておいたほうが、後からの差し替えやメンテナンスもしやすくなります。
必要なものだけ、個別ファイルで管理
そこで今回は、仮データを以下のように切り出しました(
projects
は今回は使わないです):データ用途 | ファイル | 型も一緒に定義 |
---|---|---|
ユーザー情報 | lib/mock-user.ts | ✅ User 型 |
メインメニュー | lib/mock-nav-main.ts | ✅ NavItem 型 |
チーム情報(今回は1チームだけ) | lib/mock-team.ts | ✅ Team 型 |
上記のファイルで、 「型とデータをセットで定義してエクスポートする」 方式にします。
例: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.ts
とlib/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.tsx
で mockUser
を渡すようにしています。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. ナビゲーション関連コンポーネントを整える
前章で、
user
・navMain
・team
の仮データを lib/
に切り出しました。
これらを受け取る側の UI コンポーネントも、それに合わせて修正していきます。ポイントは以下の3つです。
1. propsでデータを受け取るようにする
コンポーネントが内部で仮データを持つのではなく、外から渡されたデータを描画します。
コンポーネントが内部で仮データを持つのではなく、外から渡されたデータを描画します。
2. 型定義を共通化
lib/mock-xxx.ts
にある型(User
・NavItem
・Team
)をimport
して、props
の型として利用します。3. チーム切替UIを簡略化
今回は1チームしかない前提なので、
今回は1チームしかない前提なので、
team-switcher.tsx
はnav-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-label
とmuted
なテキストを追加。 - リンクは暫定で
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.ts
のTeam
型に合わせて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.icon
はpublic/
配下に配置した画像を指定しておけば、/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やサーバアクションへの切替のみ)
この共通レイアウトは、今後のユーザー管理画面や設定画面、ダッシュボードの各種ページでも再利用可能です。
次章では、ここに ダークモード切替UI (
ModeToggle
) を導入して、ナイトモードへの切替に対応する実装を行っていきます。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 。状態に応じて Sun /Moon アイコンを切り替え。 |
ボタンの切り替え動作 | 現在がダークモードならライトへ、ライトならダークへ切り替える挙動に設定。 |
className 対応 | props.className を受け取って <Button> に渡すことで、呼び出し側からのレイアウト調整が可能に。 |
AppSidebar
に組み込む
作成した
ModeToggle
を AppSidebar.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 dev
でlocalhost: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 - Dark Mode(Next.js)
next-themes
を用いたダークモード切り替え構成を公式が案内しているページです。ThemeProvider
の構成や注意点について記述があります。
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。