デザインについての学習メモブログ

Next.js入門 #11 実践編 UIコンポーネントライブラリ — shadcn/uiで本格デザイン

記事内に広告が含まれています。

Next.js入門 #11 実践編 UIコンポーネントライブラリ — shadcn/uiで本格デザイン

#9でデータベース、#10で認証を実装し、アプリの中身はかなり実用的になってきました。

しかし見た目はまだ、素のHTMLタグに毛が生えた程度です。

今回はshadcn/uiを導入し、ログイン画面や管理画面のUIを一気に「それらしい見た目」に引き上げます。

この記事は#7のTailwind CSSの知識が前提です。

shadcn/uiはTailwindをベースに構築されているため、Tailwindの基本的なクラスの読み方が分かっているとスムーズです。

この記事のゴール

  • shadcn/uiの仕組みと、一般的なUIライブラリとの違いを理解する
  • shadcn/uiをセットアップし、コンポーネントを追加できるようになる
  • Button・Dialog・Formを使って、投稿フォームとログインボタンを作り直す

shadcn/uiとは何か、何が違うのか

MUIやChakra UIのような一般的なUIライブラリは、npm installするとコンポーネントがnode_modulesの中にブラックボックスとして入ります。

見た目を変えたい場合は、ライブラリが用意したpropsやテーマ設定を通して間接的にカスタマイズすることになります。

shadcn/uiはこれとは考え方が異なります。

shadcn/uiは「ライブラリ」ではなく「コードジェネレーター」です。

CLIを実行すると、コンポーネントのソースコードそのものが自分のプロジェクトの中(components/ui)にコピーされます。

つまり、Buttonコンポーネントを追加した時点で、それは”自分のコード“になります。

中身を直接編集しても構いませんし、依存しているのは後述する土台のUIライブラリとTailwindだけなので、ブラックボックスがありません。

この「コピーして自分のものにする」という思想が、他のUIライブラリとの最大の違いです。

セットアップする

1. 初期化

Bash
npx shadcn@latest init

実行すると、いくつか質問されます。

Bash
? Select a component library › - Use arrow-keys. Return to submit.
   Base UI (Recommended)
    React Aria
    Radix UI

執筆時点(2026年7月)での補足

shadcn/uiは元々Radix UIだけを土台にしていましたが、現在はBase UI(Radixを作ったチームによる後継ライブラリ)・React Aria(Adobe製、多言語対応に強い)・Radix UI(従来どおり)の3つから、コンポーネントの土台となるヘッドレスUIライブラリを選べるようになっています。

2026年7月時点では、shadcn/uiチームの推奨もBase UIがデフォルトに変更されました。

どれを選んでも、ButtonvariantsizeのようなコンポーネントのAPIは共通になるよう作られているため、この記事のコード例はそのまま動作します。

迷ったら、先頭のBase UI (Recommended)を選んで進めてください。

続けて、デザインの見た目(スタイルプリセット)や配色についても聞かれます。

Bash
 Which style would you like to use?  Nova
 Which color would you like to use as base color?  Zinc
 Would you like to use CSS variables for colors?  yes

スタイルはVega(クラシックなshadcn/uiの見た目)・Nova(余白を詰めたコンパクトな見た目)など複数用意されていますが、どれを選んでも今回扱うコンポーネントの使い方自体は変わりません。

特にこだわりがなければ、デフォルトのまま進めて問題ありません。

完了すると、以下のようなファイルが生成・更新されます。

Bash
components.json       # shadcn/uiの設定ファイル
lib/utils.ts           # cn()などのユーティリティ関数
app/globals.css        # CSS変数によるカラーテーマ定義

補足

Tailwind CSS v3までは、ここにtailwind.config.tsも生成されていました。

v4ではカラーパレットなどの設定がCSS側(app/globals.css@themeブロック)に一本化されたため、tailwind.config.tsは生成されません。

もし見当たらなくても、自分で作る必要はありません。

2. コンポーネントを追加する

必要なコンポーネントは、以下のようにCLI経由で1つずつ追加していきます。

components/uiのフォルダ内を観察しながら実行すると追加される様子が目視できます。

Bash
npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add input
npx shadcn@latest add textarea
npx shadcn@latest add form
npx shadcn@latest add label

実行すると、components/ui/button.tsxのような形でソースコードが追加されます。

試しにbutton.tsxの中身を覗いてみましょう。

TSX
// components/ui/button.tsx(抜粋)
import { cva, type VariantProps } from "class-variance-authority";

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors ...",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground",
        outline: "border border-input bg-background hover:bg-accent",
        ghost: "hover:bg-accent hover:text-accent-foreground",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
      },
    },
  }
);

variantsizeによって見た目を切り替えられる、ごく普通のReactコンポーネントであることが分かります。

これが自分のプロジェクトの中に存在するので、必要であればvariantを追加したり、色を変えたりと自由に編集できます。

ログインボタンをshadcn/uiで作り直す

#10で作った素朴な<button>タグを、Buttonコンポーネントに置き換えます。

まずは、ログアウトボタンを含むauth-buttons.tsxからです。

TSX
// components/auth-buttons.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { Button } from "@/components/ui/button";
import SignInButton from "./sign-in-button";

export default async function AuthButtons() {
  const session = await auth.api.getSession({ headers: await headers() });

  if (session?.user) {
    return (
      <form
        action={async () => {
          "use server";
          await auth.api.signOut({ headers: await headers() });
          redirect("/");
        }}
        className="flex items-center gap-3"
      >
        <span className="text-sm text-muted-foreground">
          {session.user.name}さん、こんにちは
        </span>
        <Button type="submit" variant="outline" size="sm">
          ログアウト
        </Button>
      </form>
    );
  }

  return <SignInButton />;
}

続いて、onClickでBetter Authのクライアントを呼び出していたsign-in-button.tsxも置き換えます。

TSX
// components/sign-in-button.tsx
"use client";

import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";

export default function SignInButton() {
  return (
    <Button
      onClick={() =>
        authClient.signIn.social({
          provider: "google",
          callbackURL: "/",
        })
      }
    >
      Googleでログイン
    </Button>
  );
}

classNameを細かく調整しなくても、variant="outline"size="sm"を指定するだけで、統一感のあるデザインになります。

投稿フォームをDialogとFormで作り直す

これまで別ページで表示していた投稿フォームを、モーダル(Dialog)の中に収めてみましょう。

TSX
// app/posts/_components/new-post-dialog.tsx
"use client";

import { useState } from "react";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { createPost } from "../actions";

export default function NewPostDialog() {
  const [open, setOpen] = useState(false);

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger render={<Button>新しい記事を書く</Button>} />
      <DialogContent>
        <DialogHeader>
          <DialogTitle>新しい記事</DialogTitle>
        </DialogHeader>
        <form
          action={async (formData) => {
            await createPost(formData);
            setOpen(false);
          }}
          className="space-y-4"
        >
          <div className="space-y-2">
            <Label htmlFor="title">タイトル</Label>
            <Input id="title" name="title" placeholder="記事のタイトル" required />
          </div>
          <div className="space-y-2">
            <Label htmlFor="content">本文</Label>
            <Textarea id="content" name="content" rows={6} required />
          </div>
          <Button type="submit" className="w-full">
            投稿する
          </Button>
        </form>
      </DialogContent>
    </Dialog>
  );
}

DialogTriggerは、Dialogを開くためのトリガー要素としてButtonをそのまま利用できます。

ここで、選んだ土台のライブラリによって書き方が変わる点に注意してください。

選んだライブラリ書き方
Base UI(この記事のデフォルト)<DialogTrigger render={<Button>...</Button>} />
Radix UI<DialogTrigger asChild><Button>...</Button></DialogTrigger>

「トリガー要素を別の要素に差し替える」という目的は同じですが、Base UIはrenderprop、Radix UIはasChildpropと、実現方法が異なります。この違いはDialogTriggerに限らず、SheetTrigger / DropdownMenuTrigger / PopoverTrigger / TooltipTriggerなど、「トリガー」や「クローズ」に関わるコンポーネント全般で共通です。

エラーメッセージにasChildrenderという単語が出てきたら、まずこの対応関係を疑ってみてください。

投稿が成功したらsetOpen(false)でモーダルを閉じるようにしているので、Server Actionの完了後に自動的にモーダルが閉じます。

作ったDialogを記事一覧ページに埋め込む

NewPostDialogはまだどこにも配置していません。#9で作った記事一覧ページ(app/posts/page.tsx)に埋め込みましょう。

TSX
// app/posts/page.tsx
import { prisma } from "@/lib/prisma";
import NewPostDialog from "./_components/new-post-dialog";

export default async function PostsPage() {
  const posts = await prisma.post.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-xl font-bold">記事一覧</h1>
        <NewPostDialog />
      </div>
      <ul className="space-y-2">
        {posts.map((post) => (
          <li key={post.id}>
            <h2 className="font-bold">{post.title}</h2>
            <p className="text-sm text-muted-foreground">{post.content}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

app/posts/page.tsxはServer Componentですが、"use client"が付いたClient Component(NewPostDialog)を子として配置すること自体は問題ありません。

Server ComponentからClient Componentを呼び出すのは、App Routerの基本的な組み合わせ方です。

トースト通知でフィードバックを返す

投稿の成否をユーザーに伝えるため、トースト通知も追加してみましょう。

Bash
npx shadcn@latest add sonner
TSX
// app/layout.tsx
import { Toaster } from "@/components/ui/sonner";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>
        {children}
        <Toaster />
      </body>
    </html>
  );
}
TSX
// app/posts/_components/new-post-dialog.tsx(一部抜粋・変更)
"use client";

import { toast } from "sonner";
// ...

<form
  action={async (formData) => {
    try {
      await createPost(formData);
      toast.success("記事を投稿しました");
      setOpen(false);
    } catch (error) {
      toast.error("投稿に失敗しました");
    }
  }}
>

Toasterをレイアウトのルートに1つ置いておくだけで、アプリ内のどこからでもtoast.success() / toast.error()を呼び出せるようになります。

コンポーネントをカスタマイズする

shadcn/uiの最大の利点は、生成されたコードを直接編集できることです。

例えば、ボタンの角丸を少し強めにしたい場合、components/ui/フォルダにあるbutton.tsxを直接編集します。

TSX
// components/ui/button.tsx(編集例)
const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-lg text-sm font-medium ...", // rounded-md → rounded-lg に変更
  // ...
);

npmパッケージのように「上書きしたらアップデートで消える」という心配がありません。

これはコピー方式ならではのメリットです。

まとめ

この記事では、以下を行いました。

  • shadcn/uiが「コピーして自分のコードにする」思想のツールであることを理解
  • Button・Dialog・Form・Input・Textarea・Sonnerを導入
  • ログインボタンと投稿フォームを、実用的な見た目に作り直す

これで、アプリの見た目がぐっと「本物のプロダクト」らしくなりました。

次回の#12 状態管理入門 — ZustandとTanStack Queryでは、UIが複雑になるにつれて必要になる「状態管理」について、グローバルなUI状態とサーバーデータのキャッシュを分けて考える方法を学びます。