😗

shadcn/ui入門(Next.js)

に公開

はじめに

これまで私は勉強のために自身でUIを構築することが多かったですが、最近名前を聞くことが多いshadcn/uiをはじめて触ってみました。忘備録、他の方のためになればと勉強した内容をまとめたものが本記事となります。

shadcn/uiとは

shadcn/uiはReact向けのコンポーネントコレクションであり、UIをシンプルかつ柔軟に構築することを目的にしています。
一番大きな特徴としてはコンポーネントライブラリではないということです。
世の中にはMaterial-UI、Chakra UIなど有名なコンポーネントライブラリが多くあります。
これらを使用する際は、たくさんのコンポーネントを含んだパッケージを開発環境にインストールし、その中から使用するコンポーネントを使用します。
一方でshadcn/uiはコンポーネントごとにインストールを行うことができ、面倒な手間を省くことができます。
また、カスタマイズがしやすいという特徴もあります。
CSSにはTaiwindCSSを採用しており、コンポーネントごとに高い自由度でデザインの変更を行うことができます。

環境構築

プロジェクトを作成します。

npx create-next-app@latest


セットアップを行います。

npx shadcn@latest init

ベースカラーについての質問が現れます。今回はNeutralを選択します。

React19を使用している場合、依存関係についての質問が現れます。今回はUse --legacy-peer-depsを選択します。

src>lib>utils.tsとcomponents.jsonが自動生成されます。

コンポーネントを触ってみる

インストール

ボタンコンポーネントをインストールします。

npx shadcn@latest add button

src>components>ui>button.tsxが自動生成されます。

呼び出し

page.tsxでコンポーネントを呼び出してみます。

page.tsx
import { Button } from "@/components/ui/button";

export default function Home() {
  return (
    <div className="h-screen flex items-center justify-center">
      <Button>Button</Button>
    </div>
  );
}

正常に呼び出せていることがわかります。

コンポーネントの中身

自動生成されたbutton.tsxの中身を見ていきます。

button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
  {
    variants: {
      variant: {
        default:
          "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
        destructive:
          "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
        outline:
          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
        ghost:
          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2 has-[>svg]:px-3",
        sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
        icon: "size-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

function Button({
  className,
  variant,
  size,
  asChild = false,
  ...props
}: React.ComponentProps<"button"> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean
  }) {
  const Comp = asChild ? Slot : "button"

  return (
    <Comp
      data-slot="button"
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

export { Button, buttonVariants }

variants

variantsの中にはvariant、sizeのオブジェクトが入っています。

button.tsx
    variants: {
      variant: {
        default:
          "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
        destructive:
          "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
        outline:
          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
        ghost:
          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2 has-[>svg]:px-3",
        sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
        icon: "size-9",
      },
    },
variant

varianはあらかじめshadcn/uiが用意してくれているスタイルになります。すべて見てみます。

pate.tsx
import { Button } from "@/components/ui/button";

export default function Home() {
  return (
    <div className="h-screen flex items-center justify-center flex-col">
      <Button variant={"default"}>Button</Button>
      <Button variant={"destructive"}>Button</Button>
      <Button variant={"outline"}>Button</Button>
      <Button variant={"secondary"}>Button</Button>
      <Button variant={"ghost"}>Button</Button>
      <Button variant={"link"}>Button</Button>
    </div>
  );
}

size

sizeはあらかじめshadcn/uiが用意してくれているサイズに関するスタイルになります。すべて見てみます。

pate.tsx
import { Button } from "@/components/ui/button";

export default function Home() {
  return (
    <div className="h-screen flex items-center justify-center flex-col">
      <Button variant={"default"} size={"sm"}>Button</Button>
      <Button variant={"default"} size={"default"}>Button</Button>
      <Button variant={"default"} size={"lg"}>Button</Button>
    </div>
  );
}

defaultVariants

varinatとsizeのどの設定をデフォルトとするか設定することができます。

button.tsx
    defaultVariants: {
      variant: "default",
      size: "default",
    },

className

button.tsx
function Button({
  className,
  variant,
  size,
  asChild = false,
  ...props
}: React.ComponentProps<"button"> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean
  }) {
  const Comp = asChild ? Slot : "button"

  return (
    <Comp
      data-slot="button"
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

classNameを受け取ることができるように設定されています。

page.tsx
import { Button } from "@/components/ui/button";

export default function Home() {
  return (
    <div className="h-screen flex items-center justify-center flex-col">
      <Button className="bg-red-950 text-red-300 my-1">Button</Button>
      <Button className="bg-blue-950 text-blue-300 my-1">Button</Button>
      <Button className="bg-green-950 text-green-300 my-1">Button</Button>
    </div>
  );
}

Slot

button.tsxにはSlotがインポートされています。

button.tsx
import { Slot } from "@radix-ui/react-slot"

ドキュメントを確認すると、直近の子要素にpropsをマージするという説明があります。
また、buttonコンポーネントを見てみるとasChildがtrueの場合のみSlotが利用されていることがわかります。

button.tsx
function Button({
  className,
  variant,
  size,
  asChild = false,
  ...props
}: React.ComponentProps<"button"> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean
  }) {
  const Comp = asChild ? Slot : "button"

  return (
    <Comp
      data-slot="button"
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

機能を確認するためにpage.tsxを変更してデベロッパーツールで要素を確認します。

page.tsx
import { Button } from "@/components/ui/button";
import Link from "next/link";

export default function Home() {
  return (
    <div className="h-screen flex items-center justify-center flex-col">
      <Button>
        <Link href="/">Button</Link>
      </Button>
    </div>
  );
}


次にButtonコンポーネントにasChildを追加してみます。

page.tsx
import { Button } from "@/components/ui/button";
import Link from "next/link";

export default function Home() {
  return (
    <div className="h-screen flex items-center justify-center flex-col">
      <Button asChild>
        <Link href="/">Button</Link>
      </Button>
    </div>
  );
}


asChildがない場合はbuttonタグにclassが適用されており、その中にaタグがあります。
asChildがある場合はbuttonタグがなくなっており、aタグにclassが適用されています。
asCbildを設定することで、見た目には影響を与えずに親要素のbuttonでなく子要素のaとして表示することができるとわかりました。

cva

ドキュメントを見てみると利用方法が書いてあります。
第一引数に共通のcss、第二引数にoptionsを設定することができます。
cvaのおかげで、Buttonコンポーネントにvariant、sizeプロップスを渡すことでスタイルの変更が可能になっています。

カスタマイズ

button.tsx
...
red: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 text-red-100 bg-red-950",
blue: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 text-blue-100 bg-blue-950",
green:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 text-green-100 bg-green-950",
page.tsx
import { Button } from "@/components/ui/button";

export default function Home() {
  return (
    <div className="h-screen flex items-center justify-center flex-col">
      <Button variant={"red"}>Button</Button>
      <Button variant={"blue"}>Button</Button>
      <Button variant={"green"}>Button</Button>
    </div>
  );
}

テーマ

shadcn/uiにはダークモードなどに変更するための機能があります。
ダークモードとライトモードの実装をしていきます。

next-themeをインストールします。

npm install next-themes

プロバイダーを作成します。

components/theme/ThemeProvider.tsx
"use client"

import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"

export function ThemeProvider({
  children,
  ...props
}: React.ComponentProps<typeof NextThemesProvider>) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

layout.tsxでルートレイアウトをラップします。

layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme/ThemeProvider";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja" suppressHydrationWarning>
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <ThemeProvider
          attribute="class"
          defaultTheme="light"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

モード切替ボタンを作成します。

components/theme/ThemeToggle
"use client";
import { Button } from "../ui/button";
import { useTheme } from "next-themes";

const ThemeToggle = () => {
  const { theme, setTheme } = useTheme();
  const handleClick = () => {
    if (theme === "light") setTheme("dark");
    if (theme === "dark") setTheme("light");
  };

  return (
    <div>
      <Button onClick={handleClick}>DarkTheme</Button>
    </div>
  );
};

export default ThemeToggle;

ライトモードとダークモードの切り替えができるようになりました。

参考文献

https://www.shadcn.net/ja

まとめ

今回ははじめてshadcn/uiを触りましたが、簡単にUIを構築できるので今後も間違いなく使っていくことになりそうです。用意してくれているコンポーネントの種類も豊富なので今後使用していく中で慣れていきたいと思います。

Discussion