![[管理画面フォーマット制作編 #4] サイドバーのメニューと参照中ページの同期](/_next/image?url=%2Farticles%2Fnext-js%2Fsidebar-active-sync%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット制作編 #4サイドバーのメニューと参照中ページの同期
Next.js App Router + shadcn/ui のサイドバーで「いま見ているページ」を正しくハイライト
初回公開日
最終更新日
0. はじめに
前回までの記事で管理画面レイアウト・ユーザ管理を仕上げました。
まだ、メニューと現在参照中のページを同期ができていません。
本記事では、メニュー定義を1か所に集約し、現在のURLから“最長一致”で1つだけ現在地を決めるというシンプルな仕組みで、この悩みを解決します。ページ本体はSSRのまま据え置き、サイドバー側でだけ判定します。
何を解決するか(要点を一望)
典型的なつまずき | 解き方のコア | 画面での結果 |
---|---|---|
ハイライトがズレる | usePathname() のURLとメニュー定義を照合し、最長一致で1件だけ選ぶ | “そのページを代表するリンク”にだけ aria-current="page" が付く |
動的ルートが扱いにくい | 子に該当が無ければ親(例:/users )にフォールバック | /users/U00000001 では「ユーザ管理」が強調される |
親メニューの開閉がバラつく | 現在地の祖先を自動展開 | 迷子になりにくいサイドバーになる |
具体的な仕様は下記の通りです:
txt
1【ブラウザの現在URL】
2 └─ usePathname() で取得 → 例: "/users/U00000001"
3 │
4 ▼
5【メニュー定義(単一出所のツリー)MENU: MenuNode[]】
6 ┌──────────────┐
7 │ 設定 (/settings) │
8 │ └─ ユーザ管理 (/users) ──┐
9 │ ├─ 一覧 (/users) │ (match: "exact")
10 │ └─ 新規 (/users/new) │ (match: "exact")
11 └──────────────┘ │
12 │ │(※ 動的URL /users/[id] は
13 │ │ 子に列挙せず“親 /users”が受ける)
14 ▼
15【現在地判定フック useActive(MENU)】
16 1) ツリーをフラット化(親子関係も保持)
17 2) ノードごとに pathname と照合して “スコア” 計算
18 - match: "exact" → 完全一致なら 1000 + href長
19 - match: "prefix" → 前方一致なら href長
20 - match: "regex" → 正規表現の一致長(必要時のみ)
21 3) 最大スコアのノード = active
22 4) active の祖先ノードもたどって配列に
23 │
24 ▼
25【NavMain 描画】
26 - active のリンクに aria-current="page"
27 - active または祖先ノードのグループを展開(open)
28 - data-active="true" などで太字/ハイライト
29 │
30 ▼
31【結果の見え方(例)】
32 URL: /users/U00000001
33 → 子に exact が無いので親 "/users" が最長一致で勝ち
34 → サイドバーでは「ユーザ管理」が太字(aria-current="page")
35 → 「設定」グループも自動で開く
下図のようにメニューと表示中のページが一致する状態になることを目指します。

前提(ここだけ目を通せばOK)
本記事は、下記の続きとなります。
すでに Next.js App Router + shadcn/ui のレイアウト と ユーザ管理ページ群(
/users
、/users/new
、/users/[displayId]
)がある前提です。参考として、ログイン後レイアウトとユーザ管理UIの記事が整っていれば十分です。なお、ページ本体(page.tsx
)は変更しません。サイドバー側の最小追加で同期させます。この先で触るファイル(最小セット)
役割 | ファイル名 | 何を入れるか |
---|---|---|
単一出所のメニュー | menu.schema.ts | タイトル・URL・match ("exact" / "prefix" / RegExp )・子ノード |
現在地の判定 | use-active.ts | usePathname() → スコア方式で最長一致 → 現在地と祖先を返す |
リンクの薄いラッパ | nav-link.tsx | aria-current="page" とクラス切替を標準化 |
既存サイドバー | app-sidebar.tsx | <nav aria-label="メインメニュー"> など a11y 属性を付与して組み込む |
a11y(アクセシビリティ)はここだけ押さえる
ルール | 付ける場所 | 何が伝わるか |
---|---|---|
<nav aria-label="メインメニュー"> | サイドバーの外枠 | ランドマークとして「ここがメインメニュー」と分かる |
aria-current="page" | “今いるページ”のリンク1つだけ | スクリーンリーダーで「現在のページ」と読まれる |
aria-expanded / aria-controls | 親メニューの開閉ボタン/サブメニュー | 開いている/閉じている状態が伝わる |
技術スタック
Tool / Lib | Version | Purpose |
---|---|---|
React | 19.x | UIの土台。コンポーネント/フックで状態と表示を組み立てる |
Next.js | 15.x | フルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理 |
TypeScript | 5.x | 型安全・補完・リファクタリング |
shadcn/ui | latest | RadixベースのUIキット |
Tailwind CSS | 4.x | ユーティリティファーストCSSで素早くスタイリング |
1. メニュー定義を“単一出所”に(menu.schema.ts
)
「どのリンクを太字にするか」を1か所の定義から決められるようにします。
ここで作るのは――
ここで作るのは――
- 型(
MenuNode
):タイトル・URL・match
方式("exact" | "prefix" | "regex"
)・子ノード(ツリー) - 実データ(
MENU
):ダッシュボード、設定 > ユーザ管理(一覧・新規)の最小セット
“単一出所”にすると、ページ側(
/users
, /users/new
…)は無改造のまま、サイドバーだけで同期が完結します。src/lib/sidebar/menu.schema.ts
を下記の内容で作成します:tsx
1/**
2 * src/lib/sidebar/menu.schema.ts
3 * 「型」と「メニュー定義」を1ファイルに集約(単一出所)
4 */
5import type { LucideIcon } from "lucide-react";
6import { SquareTerminal, BookOpen, Settings2 } from "lucide-react";
7
8/** どのルールでURL一致を判定するか */
9export type MatchMode = "exact" | "prefix" | "regex";
10
11/** 1つのメニュー要素(ツリーの節) */
12export type MenuNode = {
13 /** 安定したキー(href が変わっても差分検知しやすくするなら独立ID推奨) */
14 id: string;
15 /** ラベル(サイドバーに表示) */
16 title: string;
17 /** 対応するURL(絶対パス。トレーリングスラッシュは付けない) */
18 href: string;
19 /** アイコン(任意) */
20 icon?: LucideIcon;
21 /**
22 * URLの一致方法:
23 * - "exact": 完全一致(例 /users と /users だけ一致)
24 * - "prefix": 前方一致(例 /users 配下すべて)
25 * - "regex": 正規表現で高度な一致(必要時のみ)
26 *
27 * 指定がなければ "prefix" を既定とするのが実務的です。
28 */
29 match?: MatchMode;
30 /** match === "regex" のときに使うパターン(未使用なら省略) */
31 pattern?: RegExp;
32 /** 子ノード(グループやサブメニュー) */
33 children?: MenuNode[];
34};
35
36/** ツリー全体 */
37export type MenuTree = MenuNode[];
38
39/**
40 * 最小のメニュー定義
41 * - ダッシュボード(/dashboard)
42 * - 設定(/settings)配下に「ユーザ管理(/users)」グループ
43 * - 一覧(/users)… exact
44 * - 新規(/users/new)… exact
45 *
46 * 動的URL(/users/[displayId])は子に列挙しません。
47 * 親(/users)が "prefix" で受け止めます。
48 */
49export const MENU: MenuTree = [
50 {
51 id: "dashboard",
52 title: "ダッシュボード",
53 href: "",
54 icon: SquareTerminal,
55 match: "prefix", // /dashboard 配下すべてを受け持つ
56 children: [
57 {
58 id: "dashboard-overview",
59 title: "全体進捗",
60 href: "/dashboard",
61 match: "exact",
62 },
63 {
64 id: "dashboard-my-project",
65 title: "Myプロジェクト",
66 href: "#",
67 match: "prefix",
68 },
69 {
70 id: "dashboard-my-task",
71 title: "Myタスク",
72 href: "#",
73 match: "prefix",
74 },
75 ],
76 },
77 {
78 id: "docs",
79 title: "ドキュメント",
80 href: "",
81 icon: BookOpen,
82 match: "prefix",
83 children: [
84 {
85 id: "docs-tutorial",
86 title: "チュートリアル",
87 href: "#",
88 match: "prefix",
89 },
90 {
91 id: "docs-changelog",
92 title: "更新履歴",
93 href: "#",
94 match: "prefix",
95 },
96 ],
97 },
98 {
99 id: "settings",
100 title: "設定",
101 href: "",
102 icon: Settings2,
103 match: "prefix",
104 children: [
105 {
106 id: "settings-projects",
107 title: "プロジェクト管理",
108 href: "#",
109 match: "prefix",
110 },
111 {
112 id: "settings-masters",
113 title: "マスタ管理",
114 href: "#",
115 match: "prefix",
116 },
117 {
118 id: "settings-users",
119 title: "ユーザ管理",
120 href: "/users",
121 match: "prefix", // /users 配下を親で受ける(動的URL対応)
122 children: [
123 { id: "users-list", title: "一覧", href: "/users", match: "exact" },
124 {
125 id: "users-new",
126 title: "新規登録",
127 href: "/users/new",
128 match: "exact",
129 },
130 // /users/[displayId] は列挙しない → 親 "/users" が最長一致で勝つ
131 ],
132 },
133 ],
134 },
135];
どうして“単一出所”が効くのか
サイドバーの強調表示(ハイライト)は、URLとメニュー定義の一致判定だけで決められます。
この章で用意した
この章で用意した
MENU
を“真実の1か所”にしておけば、今後メニューを増減してもサイドバーだけで同期が保てます。ページファイルを触らずに済むので、運用が格段に楽になります。プロパティ一覧(迷ったらこの表)
項目 | 必須 | 例 | 意味・コツ |
---|---|---|---|
id | ✔︎ | "users-new" | 安定キー。href 変更の影響を避けたいなら独立IDに |
title | ✔︎ | "新規" | 表示ラベル |
href | ✔︎ | "/users/new" | 絶対パス、末尾 / は付けない(/users/ ←非推奨) |
icon | ー | SquareTerminal | lucide-react のアイコンを指定可 |
match | ー | "exact" | 未指定は "prefix" を既定にすると運用が簡単 |
pattern | ー | /^\/topics\/\d{4}-\d{2}$/ | match: "regex" のときだけ使用(なるべく後回しに) |
children | ー | [...] | グループ配下のサブメニュー |
どの match
を選べばいい?
目的 | 向いている match | 例 | 補足 |
---|---|---|---|
そのURLだけをピンポイントで強調 | exact | /users ・/users/new | 子の葉に使うと分かりやすい |
配下を丸ごと親で受ける | prefix | 親 /users が /users/** を受ける | 動的ルートの受け皿にも最適 |
複雑パターンが必要 | regex | /^\/topics\/\d{4}-\d{2}$/ | まずは不要。要件が出たら追加でOK |
つまずきポイント(対処もセットで)
つまずき | 症状 | 確認/対処 |
---|---|---|
末尾スラッシュの揺れ | /users と /users/ で判定が不安定 | ルール:末尾 / は付けない。定義とURLを統一 |
# や ? を考慮しすぎる | ハイライトが点いたり消えたり | パスだけを見る(usePathname() で十分) |
子に動的URLを並べようとする | メニュー定義が膨らむ/維持が大変 | 子に列挙しない。親 "prefix" + “最長一致” に任せる |
regex を先に使いたくなる | 可読性・パフォーマンスが落ちがち | まずは exact /prefix で9割カバー可能 |
ここまでの“できたこと”
- メニューの型と定義を1か所に集約(単一出所)
- 動的ルートは親
"prefix"
で受ける前提を明確化 - 以降の章で、各メニューと現在URLを突き合わせて “最長一致で1件だけ” を選ぶ土台が整いました
次章では、この
MENU
を使って 現在地を決定するフック(use-active.ts
) を用意します。2. 現在地の判定フック(use-active.ts
)
この章では、現在のURL(
usePathname()
)と“単一出所のメニューMENU
”を突き合わせて、ハイライト対象を1つに決めるフックを作ります。返り値は「現在地ノード」「祖先ノード集合」と、UIで使いやすい小さな関数群(isActive
/ isAncestor
/ ariaCurrentFor
/ openByDefault
)です。まず“入出力”と“判定ルール”を表で押さえてください。
フックの入出力(使う側のイメージ)
項目 | 型 | 役割 |
---|---|---|
useActive(menu) | (menu: MenuTree) => ActiveState | メニューを渡すと“現在地と祖先”を返す |
ActiveState.active | MenuNode | null | 現在地ノード(なければ null) |
ActiveState.ancestors | Set<string> | 祖先ノードの id 集合(親→祖父…) |
isActive(id) | (id: string) => boolean | その id が現在地か |
isAncestor(id) | (id: string) => boolean | その id が現在地の祖先か |
ariaCurrentFor(id) | (id: string) => "page" | undefined | “そのリンクに aria-current="page" を付けるべきか” |
openByDefault(id) | (id: string) => boolean | グループ(親メニュー)をデフォルト展開するか(現在地 or 祖先なら true ) |
判定ルール(“最長一致”を点数化)
match | 一致条件 | スコアの付け方 | 例 |
---|---|---|---|
"exact" | pathname === href | 1000 + href.length (最優先) | /users/new と /users/new |
"prefix" | pathname === href または pathname が href + "/" から始まる | href.length (長いほど強い) | /users/abc に対して /users |
"regex" | pattern.test(pathname) | 一致テキスト長 (必要時のみ) | ^/topics/\d{4}-\d{2}$ |
“exact が勝つ。そうでなければ、より長く一致した方が勝つ。”という素朴な決め方です。
regex
はまず不要です。exact
/ prefix
だけで9割以上カバーできます。フックの作成(use-active.ts
)
src/lib/sidebar/use-active.ts
を下記の内容にて作成します:ts
1// src/lib/sidebar/use-active.ts
2"use client";
3
4import { useMemo, useCallback } from "react";
5import { usePathname } from "next/navigation";
6import type { MenuTree, MenuNode } from "./menu.schema";
7
8/* ========== 公開インターフェース ========== */
9
10export type ActiveState = {
11 active: MenuNode | null;
12 ancestors: Set<string>;
13 isActive: (id: string) => boolean;
14 isAncestor: (id: string) => boolean;
15 ariaCurrentFor: (id: string) => "page" | undefined;
16 openByDefault: (id: string) => boolean;
17};
18
19type FlatNode = {
20 id: string;
21 href: string;
22 match: "exact" | "prefix" | RegExp; // "regex" は RegExp に正規化して持つ
23 parentId: string | null;
24 depth: number;
25};
26
27// "regex" 指定を実際の RegExp に正規化
28function toFlatMatch(n: MenuNode): FlatNode["match"] {
29 if (!n.match || n.match === "exact" || n.match === "prefix") {
30 return n.match ?? "exact";
31 }
32 // n.match === "regex" の場合
33 // パターン未指定なら絶対にマッチしないダミーを返す(. ^ はどれにも一致しない)
34 return n.pattern ?? /.^/;
35}
36
37function flatten(
38 tree: MenuTree,
39 parentId: string | null,
40 depth: number,
41 out: FlatNode[],
42) {
43 for (const n of tree) {
44 out.push({
45 id: n.id,
46 href: n.href,
47 match: toFlatMatch(n), // ← ここで型を合わせる
48 parentId,
49 depth,
50 });
51 if (n.children?.length) flatten(n.children, n.id, depth + 1, out);
52 }
53}
54
55export function useActive(menu: MenuTree) {
56 const pathname = usePathname();
57
58 // 1) フラット化
59 const flat = useMemo(() => {
60 const buf: FlatNode[] = [];
61 flatten(menu, null, 0, buf);
62 return buf;
63 }, [menu]);
64
65 // ★ 子参照に使う簡易インデックス(id -> node)
66 const nodeById = useMemo(() => {
67 const map = new Map<string, MenuNode>();
68 const walk = (list: MenuNode[]) => {
69 for (const n of list) {
70 map.set(n.id, n);
71 if (n.children?.length) walk(n.children);
72 }
73 };
74 walk(menu);
75 return map;
76 }, [menu]);
77
78 // 2) スコア計算
79 const scoreFor = useCallback(
80 /* 既存のまま */ (n: FlatNode): number => {
81 const href = n.href.replace(/\/+$/, "");
82 const current = pathname.replace(/\/+$/, "");
83 if (n.match === "exact") {
84 return current === href ? 1_000_000 + href.length : 0;
85 }
86 if (n.match === "prefix") {
87 const pref = href === "" ? "/" : href + "/";
88 return current.startsWith(pref) ? href.length : 0;
89 }
90 if (n.match instanceof RegExp) {
91 const m = current.match(n.match);
92 return m ? 500_000 + (m[0]?.length ?? 0) : 0;
93 }
94 return 0;
95 },
96 [pathname],
97 );
98
99 // 3) ベストを選ぶ
100 const active = useMemo(() => {
101 const scored = flat
102 .map((n) => ({ ...n, score: scoreFor(n) }))
103 .filter((n) => n.score > 0)
104 .sort(
105 (a, b) =>
106 b.score - a.score ||
107 b.depth - a.depth ||
108 b.href.length - a.href.length,
109 );
110 return scored[0] ?? null;
111 }, [flat, scoreFor]);
112
113 // 3.5) ★ 代表リンク(/users の exact 子)に振り替えるための pageId を確定
114 const pageId = useMemo(() => {
115 if (!active) return undefined;
116 const node = nodeById.get(active.id);
117 if (!node) return active.id;
118
119 // 親が prefix で、自分と同じ href を持つ exact の子がいれば、それを代表にする
120 const exactChild = node.children?.find(
121 (c) =>
122 (c.match ?? "exact") === "exact" &&
123 c.href.replace(/\/+$/, "") === node.href.replace(/\/+$/, ""),
124 );
125 return exactChild?.id ?? active.id;
126 }, [active, nodeById]);
127
128 // 祖先判定
129 const parentById = useMemo(() => {
130 const map = new Map<string, string | null>();
131 for (const n of flat) map.set(n.id, n.parentId);
132 return map;
133 }, [flat]);
134
135 const isActive = useCallback((id: string) => active?.id === id, [active]);
136 const isAncestor = useCallback(
137 (id: string) => {
138 let p = active?.parentId ?? null;
139 while (p) {
140 if (p === id) return true;
141 p = parentById.get(p) ?? null;
142 }
143 return false;
144 },
145 [active, parentById],
146 );
147
148 // ★ aria-current は代表リンク(pageId)に付与
149 const ariaCurrentFor = useCallback<(id: string) => "page" | undefined>(
150 (id) => (pageId && pageId === id ? "page" : undefined),
151 [pageId],
152 );
153
154 const openByDefault = useCallback(
155 (id: string) => isAncestor(id) || isActive(id),
156 [isAncestor, isActive],
157 );
158
159 return { isActive, isAncestor, ariaCurrentFor, openByDefault };
160}
使い方の“型合わせ”とコツ
-
NavMain
側では、useActive(MENU)
を呼び、
aria-current
とハイライト用クラス(例:data-active="true"
)を “たった1つのリンク” にだけ付けます。
親のグループ(Collapsible
)にはopenByDefault(id)
を使うと、現在地の祖先は自動で展開されます。 -
prefix
の“境界”が大事。/users
と/users/new
は前方一致ですが、/user
(単数)とは一致しません。
下表のように 「href + "/"
から始まるか?」 を見ているため、誤爆を防げます。
href | pathname | 一致? | 理由 |
---|---|---|---|
/users | /users | ◯ | 完全一致 |
/users | /users/new | ◯ | href + "/" から始まる |
/users | /user | × | セグメント境界が違う |
/ | /anything | ◯(弱) | ルートは最弱スコア(常に負けやすい) |
- “ tie のときは長い方が勝つ ” ので、
/users/new
は/users
より強くなります。
exact
はさらに +1000 のボーナスがあるため、同じ長さなら必ずexact
が勝ちます。
ありがちエラーと直し方(早見表)
症状 | 主因 | 直し方 |
---|---|---|
ハイライトが2か所に付く | UI側で isActive 条件が重複 | “代表リンク1つにだけ aria-current="page" を付ける”ルールを徹底 |
/users/ (末尾 / )で反応が違う | 末尾スラッシュの揺れ | normalizePath() を必ず通す(定義側も末尾 / を付けない) |
/user でも「ユーザ管理」が光る | 素朴な startsWith 判定 | セグメント境界を見て href + "/" で判定する実装に修正 |
regex が効かない | pattern 未指定 | match: "regex" のときは pattern を必ず与える(不要なら regex は使わない) |
ここまでの到達点
MENU
(単一出所)と現在URLから、“最長一致でただ1つ” の現在地を計算できるようになりました。- UI は
isActive
/isAncestor
/ariaCurrentFor
/openByDefault
を使うだけ。 - 次章では、このフックを
NavMain
に組み込み、aria-current="page"
と親の自動展開を反映させます。
3. リンクの軽量ラッパー(nav-link.tsx
)
<Link>
をそのまま使うと、ページごとに「aria-current
の付け方」「太字クラスの当て方」「target="_blank"
の安全対策」などを毎回書くことになり、ムラが出がちです。ここでは “軽量ラッパー” を1つ用意し、判定は前章の
useActive()
、表現は本ラッパーという役割分担にします。役割の切り分け
レイヤー | 何をする | 何をしない |
---|---|---|
useActive() | 現在地/祖先の判定、ariaCurrentFor(id) の提供 | 見た目のクラスは決めない |
NavLink (本章) | aria-current="page" の受け取り→リンクへ付与。data-active="true" を出力。外部リンクなら rel を自動補完 | どのリンクが現在地かを決めない |
NavMain | useActive() から active /ariaCurrent を取得して NavLink に渡す。SidebarMenuButton のクラスに data-[active=true]:… を書く | URL判定ロジックは持たない |
軽量ラッパーの作成 (nav-link.tsx)
src/components/sidebar/nav-link.tsx
を下記内容にて作成します:tsx
1/* src/components/sidebar/nav-link.tsx
2 - Next.js の <Link> を“軽量に”包む
3 - 受け取った active / ariaCurrent を素直に反映
4 - 外部リンクや target="_blank" の安全対策もここで吸収
5*/
6"use client";
7
8import * as React from "react";
9import Link, { type LinkProps } from "next/link";
10
11type AriaCurrent = "page" | undefined;
12
13/** 使う側(NavMain)からは “結果” を渡してもらうだけ */
14export type NavLinkProps = LinkProps &
15 Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
16 /** そのリンクが現在地なら true(useActive() 由来) */
17 active?: boolean;
18 /** そのリンクに付与すべき aria-current(通常は "page" か undefined) */
19 ariaCurrent?: AriaCurrent;
20 };
21
22// かんたんなクラス結合
23function cx(...v: Array<string | undefined>) {
24 return v.filter(Boolean).join(" ");
25}
26
27/** “軽量ラッパー”本体:見た目ロジックは持たず、渡された結果を素直に出す */
28export const NavLink = React.forwardRef<HTMLAnchorElement, NavLinkProps>(
29 function NavLink(
30 { active, ariaCurrent, className, href, target, rel, ...rest },
31 ref,
32 ) {
33 // target="_blank" の安全対策(親から rel が来ていなければ付与)
34 const safeRel =
35 target === "_blank" ? (rel ? rel : "noopener noreferrer") : rel;
36
37 return (
38 <Link
39 ref={ref}
40 href={href}
41 aria-current={ariaCurrent}
42 data-active={active ? "true" : undefined}
43 target={target}
44 rel={safeRel}
45 className={cx(
46 // 基本形(SidebarMenuButton/SubButton の asChild でも単体でも崩れない)
47 "flex w-full items-center gap-2 rounded-md px-2 py-1.5 transition-colors",
48 // data-active でも aria-current でも強調が効くように両方用意
49 "data-[active=true]:bg-muted data-[active=true]:font-semibold",
50 "aria-[current=page]:bg-muted aria-[current=page]:font-semibold",
51 className, // ← 親(asChild)から渡ってくるクラスを必ず後ろでマージ
52 )}
53 {...rest}
54 />
55 );
56 },
57);
58
59export default NavLink;
ここがポイント
-
見た目の規約を1か所に統一
太字・ハイライトはdata-[active=true]
とaria-[current=page]
の両対応にしてあるので、親側の書き方が多少違っても強調が揃います。 -
安全な外部リンク
target="_blank"
の場合はrel="noopener noreferrer"
を自動補完。記事外でも安心して使い回せます。
次章では、この
NavLink
を使って nav-main.tsx
を実装し、useActive()
の結果 → NavLink
の属性 → サイドバーの開閉/強調 という流れを完成させます。4. NavMain を実装(src/components/sidebar/nav-main.tsx
)
ここで前回までの記事で作成した
src/components/sidebar/nav-main.tsx
を入れ替えます。やることは次の3つだけです。MENU
(単一出所)を前提に、MenuTree
型の items を受け取るuseActive(items)
の結果から、aria-current="page"
と祖先の自動展開を反映- クリック時の操作性:折り畳み状態で親をクリックしたら展開だけ(すぐ遷移しない)
既存の UI パーツ(Collapsible / SidebarMenuButton など)はそのまま活かします。
また、**サブ階層の“親”**にも
また、**サブ階層の“親”**にも
data-active
を付けられるようにし、Chevron の回転は aria-expanded
を参照するように揃えています。NavMainの修正
tsx
1/* 修正版 “全量”:src/components/sidebar/nav-main.tsx */
2"use client";
3
4import { ChevronRight } from "lucide-react";
5import {
6 Collapsible,
7 CollapsibleContent,
8 CollapsibleTrigger,
9} from "@/components/ui/collapsible";
10import {
11 SidebarGroup,
12 SidebarGroupLabel,
13 SidebarMenu,
14 SidebarMenuButton,
15 SidebarMenuItem,
16 SidebarMenuSub,
17 SidebarMenuSubButton,
18 SidebarMenuSubItem,
19 useSidebar,
20} from "@/components/ui/sidebar";
21
22import { NavLink } from "@/components/sidebar/nav-link";
23import { useActive } from "@/lib/sidebar/use-active";
24import type { MenuTree, MenuNode } from "@/lib/sidebar/menu.schema";
25
26type Props = { items: MenuTree };
27
28export function NavMain({ items }: Props) {
29 const { isActive, isAncestor, ariaCurrentFor, openByDefault } =
30 useActive(items);
31 const { state, isMobile, setOpen } = useSidebar();
32
33 // サイドバーが“collapsed(アイコンのみ)”のときは、親クリックでまずサイドバーを展開
34 const handleTopParentClick = (e: React.MouseEvent) => {
35 if (state === "collapsed" && !isMobile) {
36 setOpen(true);
37 e.preventDefault();
38 }
39 };
40
41 // ─────────────────────────────────────────────
42 // レベル0(トップ階層)を描画
43 // ─────────────────────────────────────────────
44 return (
45 <SidebarGroup>
46 <SidebarGroupLabel>メニュー</SidebarGroupLabel>
47 <SidebarMenu>
48 {items.map((node) => (
49 <TopNode
50 key={node.id}
51 node={node}
52 isActive={isActive}
53 isAncestor={isAncestor}
54 ariaCurrentFor={ariaCurrentFor}
55 openByDefault={openByDefault}
56 onTopParentClick={handleTopParentClick}
57 />
58 ))}
59 </SidebarMenu>
60 </SidebarGroup>
61 );
62}
63
64/* ========== トップ階層用:親は SidebarMenuItem、子は SidebarMenuSub で受ける ========== */
65function TopNode({
66 node,
67 isActive,
68 isAncestor,
69 ariaCurrentFor,
70 openByDefault,
71 onTopParentClick,
72}: {
73 node: MenuNode;
74 isActive: (id: string) => boolean;
75 isAncestor: (id: string) => boolean;
76 ariaCurrentFor: (id: string) => "page" | undefined;
77 openByDefault: (id: string) => boolean;
78 onTopParentClick: (e: React.MouseEvent) => void;
79}) {
80 const hasChildren = !!node.children?.length;
81
82 // 子なし(トップの葉) → そのままリンク
83 if (!hasChildren) {
84 return (
85 <SidebarMenuItem>
86 <SidebarMenuButton
87 asChild
88 tooltip={node.title}
89 className="data-[active=true]:bg-muted data-[active=true]:font-semibold"
90 >
91 <NavLink
92 href={node.href}
93 active={isActive(node.id)}
94 ariaCurrent={ariaCurrentFor(node.id)}
95 >
96 {node.icon && <node.icon className="size-4" />}
97 <span>{node.title}</span>
98 </NavLink>
99 </SidebarMenuButton>
100 </SidebarMenuItem>
101 );
102 }
103
104 // 子あり(トップの親) → Collapsible。親は“開閉のみ”
105 return (
106 <Collapsible
107 asChild
108 defaultOpen={openByDefault(node.id)}
109 className="group/collapsible"
110 >
111 <SidebarMenuItem>
112 <CollapsibleTrigger asChild>
113 <SidebarMenuButton
114 tooltip={node.title}
115 onClick={onTopParentClick}
116 data-active={
117 isActive(node.id) || isAncestor(node.id) ? "true" : undefined
118 }
119 className="data-[active=true]:bg-muted data-[active=true]:font-semibold"
120 >
121 {node.icon && <node.icon className="size-4" />}
122 <span>{node.title}</span>
123 <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
124 </SidebarMenuButton>
125 </CollapsibleTrigger>
126
127 <CollapsibleContent>
128 <SidebarMenuSub>
129 {node.children?.map((child) => (
130 <SubNode
131 key={child.id}
132 node={child}
133 isActive={isActive}
134 isAncestor={isAncestor}
135 ariaCurrentFor={ariaCurrentFor}
136 openByDefault={openByDefault}
137 />
138 ))}
139 </SidebarMenuSub>
140 </CollapsibleContent>
141 </SidebarMenuItem>
142 </Collapsible>
143 );
144}
145
146/* ========== サブ階層用:子を持つなら“さらに”Collapsible、持たなければリンク ========== */
147function SubNode({
148 node,
149 isActive,
150 ariaCurrentFor,
151 openByDefault,
152}: {
153 node: MenuNode;
154 isActive: (id: string) => boolean;
155 isAncestor: (id: string) => boolean;
156 ariaCurrentFor: (id: string) => "page" | undefined;
157 openByDefault: (id: string) => boolean;
158}) {
159 const hasChildren = !!node.children?.length;
160
161 // 子なし(葉) → リンク
162 if (!hasChildren) {
163 return (
164 <SidebarMenuSubItem>
165 <SidebarMenuSubButton
166 asChild
167 className="data-[active=true]:bg-muted data-[active=true]:font-semibold"
168 >
169 <NavLink
170 href={node.href}
171 active={isActive(node.id)}
172 ariaCurrent={ariaCurrentFor(node.id)}
173 >
174 <span>{node.title}</span>
175 </NavLink>
176 </SidebarMenuSubButton>
177 </SidebarMenuSubItem>
178 );
179 }
180
181 // 子あり(サブ階層の親) → サブ内 Collapsible。親は開閉のみ(リンクにしない)
182 return (
183 <SidebarMenuSubItem>
184 <Collapsible defaultOpen={openByDefault(node.id)}>
185 <CollapsibleTrigger asChild>
186 <SidebarMenuSubButton
187 asChild
188 className="data-[active=true]:bg-muted data-[active=true]:font-semibold"
189 >
190 {/* ← 最終的なトリガー要素は <button> */}
191 <button
192 type="button"
193 data-active={openByDefault(node.id) ? "true" : undefined}
194 className="group flex w-full items-center justify-between"
195 >
196 <span>{node.title}</span>
197 {/* ← トリガーの aria-expanded を見て回転 */}
198 <ChevronRight className="ml-1 size-4 transition-transform duration-200 group-aria-[expanded=true]:rotate-90" />
199 </button>
200 </SidebarMenuSubButton>
201 </CollapsibleTrigger>
202
203 <CollapsibleContent>
204 <SidebarMenuSub className="ml-2">
205 {node.children?.map((gchild) => (
206 <SidebarMenuSubItem key={gchild.id}>
207 <SidebarMenuSubButton
208 asChild
209 className="data-[active=true]:bg-muted data-[active=true]:font-semibold"
210 >
211 <NavLink
212 href={gchild.href}
213 active={isActive(gchild.id)}
214 ariaCurrent={ariaCurrentFor(gchild.id)}
215 >
216 <span>{gchild.title}</span>
217 </NavLink>
218 </SidebarMenuSubButton>
219 </SidebarMenuSubItem>
220 ))}
221 </SidebarMenuSub>
222 </CollapsibleContent>
223 </Collapsible>
224 </SidebarMenuSubItem>
225 );
226}
置き換えのコツ(最短チェック)
箇所 | 期待する状態 | 確認ポイント |
---|---|---|
型 | type Props = { items: MenuTree } | 旧 NavItem 参照が残っていない |
判定 | useActive(items) を呼ぶ | isActive / ariaCurrentFor / openByDefault を使用 |
Link | すべて <NavLink> | SidebarMenu(Button/SubButton) に data-[active=true]:… を記述 |
折り畳み時の挙動 | 親クリックでまず展開 | handleTopParentClick で e.preventDefault() が効いている |
サブ親の強調 | サブ親にも data-active | 「ユーザ管理」など開閉専用の親も太字に |
動作確認の目安
/users
→ 「設定」「ユーザ管理」「一覧」が順に強調。aria-current="page"
は「一覧」のみ/users/new
→ 「設定」「ユーザ管理」「新規登録」/users/U00000001
→ 子に exact が無いので「ユーザ管理」が強調(最長一致)。さらに「一覧」を代表リンクとしてaria-current="page"
を付与したい場合は、useActive
の拡張(表示側ルール)で「詳細は一覧の代表扱い」を選ぶ設計も可能- サイドバーをアイコンのみ(collapsed)にして親をクリック → まず展開、その後子を選べる
ここまでで、「判定は1か所(
useActive
)・表現は統一(NavLink
)・親は開閉のみ」が揃いました。次章で app-sidebar.tsx
に MENU
を渡して締めます。5. Sidebar 組み込み(app-sidebar.tsx
)
最後に、サイドバーへ
MENU
を“正式採用” します。ポイントは2つだけです。NavMain
にitems={MENU}
を渡す(mockNavMain
は廃止)- a11y:
<nav aria-label="メインメニュー">
を付ける
app-sidebar.tsx
の修正
下記のように
src/components/sidebar/app-sidebar.tsx
を修正します。tsx
1// src/components/sidebar/app-sidebar.tsx
2"use client";
3
4import * as React from "react";
5
6import { ModeToggle } from "@/components/sidebar/mode-toggle";
7import { NavMain } from "@/components/sidebar/nav-main";
8import { NavUser } from "@/components/sidebar/nav-user";
9import { NavTeam } from "@/components/sidebar/nav-team";
10
11import {
12 Sidebar,
13 SidebarContent,
14 SidebarFooter,
15 SidebarHeader,
16 SidebarRail,
17} from "@/components/ui/sidebar";
18
19import { mockTeam } from "@/lib/sidebar/mock-team";
20import { mockUser } from "@/lib/sidebar/mock-user";
21// ★ 単一出所に統一:ここからメニューを取る
22import { MENU } from "@/lib/sidebar/menu.schema";
23
24export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
25 return (
26 <Sidebar collapsible="icon" {...props}>
27 <SidebarHeader>
28 <NavTeam team={mockTeam} />
29 </SidebarHeader>
30
31 <SidebarContent>
32 {/* a11y ランドマーク:メインメニュー */}
33 <nav aria-label="メインメニュー">
34 <NavMain items={MENU} />
35 </nav>
36 </SidebarContent>
37
38 <SidebarFooter>
39 <ModeToggle className="ml-auto" />
40 <NavUser user={mockUser} />
41 </SidebarFooter>
42
43 <SidebarRail />
44 </Sidebar>
45 );
46}
置き換え後に見るべきところ
- ページ本体はSSRのまま(
usePathname()
はサイドバー内=クライアントでのみ使用) - ランドマーク
<nav>
によってスクリーンリーダーで「ここがメインメニュー」と把握できる - 以後、新規タブやパンくず自動生成なども**
MENU
から派生**させやすい
既存の
NavTeam
/ NavUser
/ ModeToggle
/ SidebarRail
などは無改造でそのまま使えます。動作確認
ここまで来たら、一旦動作確認が可能です。
npm run dev
でテスト実行して、/users
にアクセスしてみてください。下図のようにメニューとページの同期がとれた状態になったことが確認できます。
アクセス先 | 期待する見え方 |
---|---|
/users | 「設定 ▸ ユーザ管理 ▸ 一覧」にハイライト(aria-current="page" ) |
/users/new | 「設定 ▸ ユーザ管理 ▸ 新規」にハイライト |
/users/U00000001 | 子に exact が無いので「ユーザ管理」だけが強調(最長一致) |
サイドバー collapsed | 親(例「設定」)をクリック → 展開 → 子を選んで遷移 |
補足:ライトモードでアクティブなメニューが少しわかりづらい点
これは好みになりますが、Shadcn/uiのデフォルトのCSSだと、ライトモードのときに、アクティブなメニューが判別づらいと感じました。
こうしたときは
私はこれを
これは好みになりますが、Shadcn/uiのデフォルトのCSSだと、ライトモードのときに、アクティブなメニューが判別づらいと感じました。
こうしたときは
src/app/globals.css
のroot:
ブロックを調整します。--muted: oklch(0.97 0 0);
がアクティブ時のCSSとして利用されています。私はこれを
--muted: oklch(0.95 0 0);
にして、少し判別しやすく調整しました。これで、ページ本体は SSR のまま・サイドバーだけで現在地同期が完了です。以降、メニュー追加・順序変更は
menu.schema.ts
のみを編集すれば、強調・展開・aria-current
が自動で追随します。6. a11y チェック
“どのページにいるか”を目と耳の両方に伝えるのが a11y。ここでは、本稿の実装で押さえるべきポイントを 最小限のコード+確認表 にまとめます。すでに実装済み(
NavLink
/ NavMain
/ app-sidebar
)なので、最後にセルフチェックするだけで OK です。ランドマーク:サイドバーを <nav>
で囲う
スクリーンリーダーに「ここがメインメニュー」と伝えるため、
NavMain
を <nav aria-label="メインメニュー">
で包みます(実装済み)。▼該当箇所:
tsx
1// src/components/sidebar/app-sidebar.tsx(抜粋)
2<SidebarContent>
3 <nav aria-label="メインメニュー">
4 <NavMain items={MENU} />
5 </nav>
6</SidebarContent>
補足:
aria-label
は “メニュー” の意味が分かる短い日本語であれば OK です。「いまのページ」を 1 件だけ:aria-current="page"
リンクの代表 1 件だけに
aria-current="page"
を付けます。判定は useActive()
、付与は NavLink
が担当(実装済み)。▼該当箇所:
tsx
1// src/components/sidebar/nav-link.tsx(抜粋)
2<Link
3 href={href}
4 aria-current={ariaCurrent} // ← "page" か undefined をそのまま渡す
5 data-active={active ? "true" : undefined}
6/>
ポイント:
aria-current
を付けるのは 本当に “そのページを代表するリンク” だけ。親の開閉ボタンには付けません。開閉 UI:ボタンは aria-expanded
が付く(Radix/shadcn が自動付与)
親メニューは リンクではなくボタン。
CollapsibleTrigger
を使うと、開閉に応じて aria-expanded
が自動で更新されます(手動で付ける必要はありません)。▼該当箇所:
tsx
1// src/components/sidebar/nav-main.tsx(抜粋)
2<Collapsible>
3 <CollapsibleTrigger asChild>
4 <SidebarMenuButton
5 // 開いているときは aria-expanded="true"(Radix が管理)
6 // “親は開閉のみ、遷移しない” のがポイント
7 >
8 <span>{node.title}</span>
9 </SidebarMenuButton>
10 </CollapsibleTrigger>
11 <CollapsibleContent>…</CollapsibleContent>
12</Collapsible>
補足:親に URL を持たせて遷移させると “開閉 vs 遷移” が競合して混乱しがち。親は開閉だけにしておくのが安全です。
色だけに頼らない強調(視覚的な配慮)
NavLink
は aria-current
と data-active
の両方で強調クラスを当てています。太字(font-weight)や背景色を併用することで、色覚に依存しない差異を作れます(実装済み)。tsx
1// src/components/sidebar/nav-link.tsx(抜粋)
2className={cx(
3 "flex w-full items-center gap-2 rounded-md px-2 py-1.5 transition-colors",
4 "data-[active=true]:bg-muted data-[active=true]:font-semibold",
5 "aria-[current=page]:bg-muted aria-[current=page]:font-semibold",
6)}
TIPS:
font-semibold
(太字)を併用しておくと、モノクロ環境でも “現在地” が判別しやすくなります。キーボード操作の目安
操作 | 期待される挙動 |
---|---|
Tab / Shift + Tab | フォーカスがメニュー内のボタン/リンク間を順に移動 |
Enter or Space (親) | 親メニューの開閉(aria-expanded が更新) |
Enter (子リンク) | 該当ページへ遷移 |
Esc (ツールチップ等が出ていれば) | クローズ |
補足:shadcn/ui(Radix)コンポーネントは上記の基本操作をカバー済みです。独自のキーバインドを追加する必要はありません。
セルフチェック(すぐ終わるテスト)
見るところ | どうなっていれば OK? | 確認方法 |
---|---|---|
ランドマーク | <nav aria-label="メインメニュー"> がある | DevTools Elements で確認 |
現在地リンク | aria-current="page" が 1 件だけ付く | /users と /users/new をそれぞれ開く |
親の開閉 | 折り畳み時に aria-expanded が false → true に変わる | 親のボタンを Enter/クリック |
視覚強調 | 現在地リンクが太字+背景色で強調される | 目視/強調クラスの有無を確認 |
誤判定防止 | /user では「ユーザ管理」が光らない | パス境界の判定(/users と区別)を確認 |
- ランドマーク(
nav
)+aria-current
(代表 1 件)+aria-expanded
(親の開閉) の三点セットだけで、今回のサイドバーは a11y 的に必要十分。 - 以降は
MENU
定義を編集するだけで、強調・展開・aria-current
が自動で追随します。
7.補足:layout.tsx
とpage.tsx
前回までの記事で、
src/app/(protected)/layout.tsx
とsrc/app/(protected)/
配下の各ページについて、下記のようにしていました。tsx : layout.tsx
1// src/app/(protected)/layout.tsx(抜粋)
2・・・省略・・・
3 return (
4 <SidebarProvider>
5 {/* サイドバー/ヘッダ/パンくずは“各 page.tsx”で自由に */}
6 {children}
7 <Toaster richColors closeButton />
8 </SidebarProvider>
9 );
10・・・省略・・・
tsx : pages.tsx
1// src/app/(protected)/dashboard/page.tsx(抜粋)
2・・・省略・・・
3 return (
4 <>
5 <AppSidebar />
6 <SidebarInset>
7 ・・・省略・・・
8 </SidebarInset>
9 </>
10 );
11・・・省略・・・
これは当初
<AppSidebar />
に引数を渡すような仕様を想定していたためでした。しかし、今回、URLのパスからアクティブなメニューを判定する仕様にしました。これによって、page.tsx
側で<AppSidebar />
を持つ必要がなくなりました。したがって、下記のように変更しました。
tsx : layout.tsx
1// src/app/(protected)/layout.tsx(抜粋)
2・・・省略・・・
3 return (
4 <SidebarProvider>
5 <AppSidebar />
6 <SidebarInset className="min-w-0">
7 {/* サイドバー/ヘッダ/パンくずは“各 page.tsx”で自由に */}
8 {children}
9 <Toaster richColors closeButton />
10 </SidebarInset>
11 </SidebarProvider>
12 );
13・・・省略・・・
tsx : pages.tsx
1// src/app/(protected)/dashboard/page.tsx(抜粋)各ページ同様
2・・・省略・・・
3 return (
4 <>
5 <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">
6 ・・・省略・・・
7 </header>
8 <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
9 ・・・省略・・・
10 </div>
11 </>
12 );
13・・・省略・・・
<AppSidebar />
と <SidebarInset>
をlayout.tsx
側に移動させました。<SidebarInset>
については データテーブルの横幅に影響がでないように<SidebarInset className="min-w-0">
を共通のものとしています。これで、page.tsx
は本当に必要な内容だけを記述すればよくなりました。8. まとめと次回予告
ここまでで「サイドバーのメニューと参照中ページの同期」を実装しました。ページ本体(SSR)は無改造のまま、サイドバー(Client)だけで現在地を判定・表示します。
今日やったこと(要点だけ一望)
テーマ | 中身 | 得られた効果 |
---|---|---|
単一出所のメニュー | MENU: MenuTree を src/lib/sidebar/menu.schema.ts に集約 | 追加・順序変更が1ファイルで完結 |
現在地の判定 | useActive(menu) :usePathname() × 最長一致(exact>prefix>regex) | ハイライトが常に1か所に定まる |
軽量ラッパー | NavLink :aria-current="page" と data-active を受けて出すだけ | a11yと見た目の規約を再利用 |
本体実装 | NavMain :親は開閉ボタン、子はリンク/Chevron回転は aria-expanded | 操作性(折り畳み時の誤遷移防止)と一貫表現 |
組み込み | app-sidebar.tsx に items={MENU} /<nav aria-label="メインメニュー"> | ランドマークと現在地が揃って伝わる |
以後は
menu.schema.ts
を編集するだけで、強調・展開・aria-current
が自動追随します。次回予告:プロフィール系の2画面を作る
サイドバー下部のユーザメニューから遷移する 「ユーザー情報確認」 と 「パスワード変更」 を追加する予定です。
参考文献
本稿で扱った「現在地の判定」「サイドバーの開閉」「a11y属性(
aria-current
/aria-expanded
)」の根拠や、実装に使った主要ライブラリの一次情報です。Next.js
- Next.js App Router – Navigation(
usePathname
) - Next.js App Router – Routing / Dynamic Routes
- Metadata(
metadata
の使い方)
shadcn/ui / Radix UI
- shadcn/ui – Components(Sidebar, Breadcrumb, Button などの実装リファレンス)
- shadcn/ui – Collapsible(Radix の Collapsible を用いた開閉UI)
- Radix UI – Collapsible(
data-state="open/closed"
・トリガーの仕様) - sonner(
<Toaster />
の通知)
アクセシビリティ(WAI-ARIA / MDN)
- Navigation ランドマーク(
<nav aria-label="…">
の考え方) - Disclosure / Accordion パターン(開閉UIの a11y 付与)
aria-current
属性(現在地リンクの表明)aria-expanded
属性(開閉状態の表明)
Tailwind CSS
- Tailwind v4 – Data / ARIA Variants(
data-[active=true]
やaria-[expanded=true]
) - Tailwind – Typography / Utility クラス(強調やレイアウトの基本)
React / TypeScript(補助)
- React –
forwardRef
(NavLink
の薄いラッパー実装で使用) - TypeScript – 型の絞り込み(
RegExp
と union 型の扱い)
Githubリポジトリ
この記事で作成した内容は下記のGithubリポジトリにアップしています。ご参考にどうぞ。
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
▼ 関連記事
[管理画面フォーマット制作編 #5] ユーザープロフィールUI ─ 情報確認・編集・パスワード変更
管理画面に「プロフィール」ページを追加し、ユーザ自身が情報やパスワードを更新できるUIを作成
2025/8/22公開
![[管理画面フォーマット制作編 #5] ユーザープロフィールUI ─ 情報確認・編集・パスワード変更のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fuser-profile-ui%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #3] Shadcn/uiで作るユーザ管理UI ─ 詳細・新規・編集フォーム実装
管理者向けのユーザ詳細表示・新規登録・編集画面をShadcn/uiとReact Hook Form、Zodを組み合わせて実装
2025/8/16公開
![[管理画面フォーマット制作編 #3] Shadcn/uiで作るユーザ管理UI ─ 詳細・新規・編集フォーム実装のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fuser-management-ui%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #2] Shadcn/uiで作るログイン後の管理画面レイアウト
Shadcn/uiで簡単に管理画面UIを構築。共通ヘッダ、サイドメニューなどの基本レイアウトを作成
2025/8/8公開
![[管理画面フォーマット制作編 #2] Shadcn/uiで作るログイン後の管理画面レイアウトのイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fdashboard-layout%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #1] Shadcn/uiで作るログイン画面
Shadcn/uiを利用してログインを画面作成。UIのほかZodによるバリデーションなどを実践
2025/7/24公開
![[管理画面フォーマット制作編 #1] Shadcn/uiで作るログイン画面のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Flogin-form%2Fhero-thumbnail.jpg&w=1200&q=75)
Next.js+shadcn/uiのインストールと基本動作のまとめ
開発環境(ローカルPC)にNext.js 15とshadcn/uiをインストールして、基本の動作を確認
2025/6/20公開
