shadcn-uiを全体的に網羅しよう（基礎コンポーネント編）




# はじめに
最近(結構前から)何かと有名な**shadcn/ui** は、React や Next.js で使える柔軟な UI コンポーネントセットで、主に **Radix UI** と **Tailwind CSS** を活用しています。

Radix UI をベースにしており、アクセシビリティに優れたコンポーネントを提供しています。

また、Tailwind CSS との統合により、スタイルのカスタマイズがものすごく簡単です。

なんか昔はshadcn-uiだったけどいつの間にかshadcn/uiになってました...時の流れは早いですね...

https://ui.shadcn.com/

# 全体の概要
shadcn-uiでは簡単に以下のものが作れます

* 選択タブ


![スクリーンショット 2024-12-11 15.20.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/a3267cf2-3ab7-fabf-5be2-3448448b907f.png)


* Drawer
![スクリーンショット 2024-12-11 15.21.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/ae1f0ca2-3f9e-d4bf-f00a-4eabc0708b39.png)

これらはいくつかの組み合わせでできています。

例えば、タブに関しては、後で解説するこのようなものの組み合わせになっています。

![スクリーンショット 2024-12-11 15.34.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/76508d8f-a5e6-7e59-5863-7b061268ac94.png)

なので、今回はInputやLabelといった基礎的な部分をおさらいした上で、次回のパートで細かく書いていきたいと思います。

もちろん、知っていることばっかですし、チュートリアル見たらわかることなので、知っている方は飛ばしてください🙏🙏

# 何が優れているか
MaterialUIやChakuraUIなどがかなり有名で、それぞれの強みがありますが、なんといってもTailwindCSSとそのまま活用できる点で明確に差別化されていると言えます。

# 基本的な使い方
今回はNext.jsをベースに解説していきます。
とはいっても、Nextだけではなく、Viteなども選択可能です。(一応vueもあるみたい...)

https://ui.shadcn.com/docs/installation

以下のものを打つとインストールできます。
`package.json`下で行なってください。
```shell
npx shadcn@latest init
```
コンポーネントを入れるときは、以下のように選択してください。
すると選択肢が出てきます。
```shell
npx shadcn@latest add 

#以下のものが出てくるので、選択する
? Which components would you like to add? › Space to select. A to toggle all. Enter to submit. 
◯   accordion
◯   alert
◯   alert-dialog
◯   aspect-ratio
◯   avatar
◯   badge
◯   breadcrumb
◯   button
◯   calendar
.....
```

# 有名なものからご紹介
# 入力と選択(Button,Input,TextArea)

## Button
![newlog.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/2a5a1355-03bd-beb2-b813-e66796ca998c.gif)

独自のセレクタは
* variant→視覚的なスタイルを定義する
* disabaled→ボタンを押せないようにする

ローディングに関したは、UseStateで管理します。

lucide-reactはアイコンをインポートするときによく使います。

```typescript
"use client"

import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Loader2, Send, Trash2 } from 'lucide-react'

export default function CustomButtons() {
  const [loading, setLoading] = useState(false)

  const handleLoadingClick = () => {
    setLoading(true)
    setTimeout(() => setLoading(false), 2000)
  }

  return (
    <div className="flex flex-col items-center space-y-4 p-8">
      <Button variant="default" size="lg" className="w-48">
        デフォルトボタン
      </Button>

      <Button variant="destructive" size="sm" className="rounded-full">
        <Trash2 className="mr-2 h-4 w-4" />
        削除
      </Button>

      <Button
        variant="outline"
        className="bg-gradient-to-r from-pink-500 to-yellow-500 hover:from-pink-600 hover:to-yellow-600 text-white border-none"
      >
        グラデーションボタン
      </Button>

      <Button variant="ghost" className="text-lg font-semibold tracking-wide">
        ゴーストボタン
      </Button>

      <Button variant="link" className="underline-offset-4 hover:underline-offset-8 transition-all duration-300">
        リンクボタン
      </Button>

      <Button
        variant="secondary"
        className="relative overflow-hidden group"
        onClick={handleLoadingClick}
        disabled={loading}
      >
        {loading ? (
          <Loader2 className="h-4 w-4 animate-spin" />
        ) : (
          <>
            <span className="relative z-10">ホバーエフェクト</span>
            <span className="absolute inset-0 bg-primary transform scale-x-0 group-hover:scale-x-100 transition-transform origin-left duration-300" />
          </>
        )}
      </Button>

      <Button asChild>
        <a href="https://example.com" target="_blank" rel="noopener noreferrer" className="flex items-center">
          <Send className="mr-2 h-4 w-4" />
          送信
        </a>
      </Button>
    </div>
  )
}


```


## Input/TextArea

まずはinputからです。

![スクリーンショット 2024-12-11 17.00.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/7d745d77-7530-1497-bc66-0fddf53cc4c7.png)

* type→default,password,date,fileなど基本的なinputと同じです
* placeholderも同様にあります。

Labelに関しては、Inputなどにidを指定しておけば、Labelにidを指定すれば連動され、タイトルのような役割を果たします。
（実際には、タグが追従され、アクセシビリティが向上されます。）

パスワードの開閉はuseStateを使って、textにするかpasswordにするかで判別します。


```typescript
"use client"

import { useState } from "react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Eye, EyeOff } from 'lucide-react'

export default function CustomInputs() {
  const [showPassword, setShowPassword] = useState(false)

  return (
    <div className="flex flex-col space-y-6 p-8 max-w-md mx-auto">
      {/* 基本的なInput */}
      <div className="space-y-2">
        <Label htmlFor="default-input">デフォルトInput</Label>
        <Input id="default-input" placeholder="ここに入力してください" />
      </div>

      {/* パスワードInput */}
      <div className="space-y-2">
        <Label htmlFor="password-input">パスワードInput</Label>
        <div className="relative">
          <Input
            id="password-input"
            type={showPassword ? "text" : "password"}
            placeholder="パスワード"
            className="pr-10"
          />
          <button
            type="button"
            onClick={() => setShowPassword(!showPassword)}
            className="absolute right-3 top-1/2 transform -translate-y-1/2"
          >
            {showPassword ? (
              <EyeOff className="text-gray-400 h-5 w-5" />
            ) : (
              <Eye className="text-gray-400 h-5 w-5" />
            )}
          </button>
        </div>
      </div>

      {/* カスタムスタイルInput */}
      <div className="space-y-2">
        <Label htmlFor="custom-input">カスタムスタイルInput</Label>
        <Input
          id="custom-input"
          placeholder="カスタム"
          className="border-2 border-purple-500 focus:border-purple-700 focus:ring-purple-700 rounded-lg shadow-sm"
        />
      </div>

      {/* ファイルInput */}
      <div className="space-y-2">
        <Label htmlFor="file-input">ファイルInput</Label>
        <Input
          id="file-input"
          type="file"
          className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-violet-50 file:text-violet-700 hover:file:bg-violet-100"
        />
      </div>

      {/* 数値Input */}
      <div className="space-y-2">
        <Label htmlFor="number-input">数値Input</Label>
        <Input
          id="number-input"
          type="number"
          placeholder="数値を入力"
          min={0}
          max={100}
          step={1}
        />
      </div>

      {/* 日付Input */}
      <div className="space-y-2">
        <Label htmlFor="date-input">日付Input</Label>
        <Input
          id="date-input"
          type="date"
          className="text-gray-500"
        />
      </div>
    </div>
  )
}

```

文字数が多い時は、textareaを使うと、長さの調整などができます。

![スクリーンショット 2024-12-11 17.06.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/d222a68c-6267-853b-6f15-48b2673612b6.png)

* inputと違い、maxlengthなどの違いがあります。
* カウントしたい時はuseStateで管理します。


```typescript
"use client"

import { useState } from "react"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"

export default function CustomTextareas() {
  const [characterCount, setCharacterCount] = useState(0)

  return (
    <div className="flex flex-col space-y-6 p-8 max-w-md mx-auto">
      {/* 基本的なTextarea */}
      <div className="space-y-2">
        <Label htmlFor="default-textarea">デフォルトTextarea</Label>
        <Textarea id="default-textarea" placeholder="ここに長文を入力してください" />
      </div>

      {/* 文字数カウント付きTextarea */}
      <div className="space-y-2">
        <Label htmlFor="count-textarea">文字数カウント付きTextarea</Label>
        <div className="relative">
          <Textarea
            id="count-textarea"
            placeholder="最大100文字まで入力できます"
            maxLength={100}
            onChange={(e) => setCharacterCount(e.target.value.length)}
            className="pr-16"
          />
          <span className="absolute bottom-2 right-2 text-sm text-gray-400">
            {characterCount}/100
          </span>
        </div>
      </div>

      {/* 自動リサイズTextarea */}
      <div className="space-y-2">
        <Label htmlFor="auto-resize-textarea">自動リサイズTextarea</Label>
        <Textarea
          id="auto-resize-textarea"
          placeholder="入力すると自動的に高さが調整されます"
          className="min-h-[100px] max-h-[300px] overflow-hidden"
          onChange={(e) => {
            e.target.style.height = 'auto'
            e.target.style.height = e.target.scrollHeight + 'px'
          }}
        />
      </div>
    </div>
  )
}


```


# 選択型状態管理(CheckBox,RadioBox,ToggleGroup)
基本的にどれも、準備した配列を回して、要素の状態を管理していきます。
状態管理＋表示をしていきます。

## checkbox
複数選択できます。
chakedの判定は、配列の中に入っているかどうかで決めるので、初期状態はnullです。
現在選択しているものを管理するには、useStateの配列を使い、onCheckedChangeが押されるたびに以下の動作が呼び出されます。


* checked がtrueなら、selectedFruitsに果物を追加します（配列のコピーを作るため...selectedFruitsを使用）。
* checked がfalseなら、選択を解除された果物を配列から取り除きます（filterメソッドを使用）。

これらの動作は独立しており、fruit配列を回していくのでつまり、全体を覆うものはありません。

![スクリーンショット 2024-12-11 17.55.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/10da08ba-23be-34af-d518-0acb3ec1a5e4.png)

 ```typescript
 "use client"

import { useState } from "react"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"

export default function CustomCheckboxGroup() {
  const [selectedFruits, setSelectedFruits] = useState<string[]>([])

  const fruits = ["りんご", "バナナ", "オレンジ", "いちご"]

  return (
    <div className="space-y-4">
      <h2 className="text-lg font-semibold">好きな果物を選んでください</h2>
      {fruits.map((fruit) => (
        <div key={fruit} className="flex items-center space-x-2">
          <Checkbox
            id={fruit}
            checked={selectedFruits.includes(fruit)}
            onCheckedChange={(checked) => {
              setSelectedFruits(
                checked
                  ? [...selectedFruits, fruit]
                  : selectedFruits.filter((f) => f !== fruit)
              )
            }}
          />
          <Label htmlFor={fruit}>{fruit}</Label>
        </div>
      ))}
      <p className="text-sm text-gray-500">
        選択された果物: {selectedFruits.join(", ") || "なし"}
      </p>
    </div>
  )
}


 ```


## RadioGroup
単一の選択ができます。
逆にこちらは他の選択に従属しているので、全体をRadioGroupで囲っています。

中のものはRadioItemで囲っています。選択された物の状態はvalueで管理することができます。

![スクリーンショット 2024-12-11 17.55.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/174ca11c-1a7c-78e0-ee61-7cc2f9e967c8.png)

 ```typescript
 "use client"

import { useState } from "react"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"

export default function CustomRadioGroup() {
  const [selectedPlan, setSelectedPlan] = useState("basic")

  const plans = [
    { value: "basic", label: "ベーシック", price: "¥1,000/月" },
    { value: "pro", label: "プロ", price: "¥2,000/月" },
    { value: "enterprise", label: "エンタープライズ", price: "¥5,000/月" },
  ]

  return (
    <div className="space-y-4">
      <h2 className="text-lg font-semibold">プランを選択してください</h2>
      <RadioGroup value={selectedPlan} onValueChange={setSelectedPlan}>
        {plans.map((plan) => (
          <div key={plan.value} className="flex items-center space-x-2">
            <RadioGroupItem value={plan.value} id={plan.value} />
            <Label htmlFor={plan.value} className="flex justify-between w-full">
              <span>{plan.label}</span>
              <span className="text-gray-500">{plan.price}</span>
            </Label>
          </div>
        ))}
      </RadioGroup>
      <p className="text-sm text-gray-500">
        選択されたプラン: {selectedPlan}
      </p>
    </div>
  )
}


 ```

## Toggle Group
選択自体をボックスにします。複数選択です。
type="multiple"とすることで、複数選択の状態を、valueで管理できるようになります。
こちらも同様にGroupに対してItemで分けています。

![newlog.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/0f66eb01-2cf6-1595-6103-f3c5cce90ce5.gif)

 ```typescript
 "use client"

import { useState } from "react"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { Bold, Italic, Underline } from 'lucide-react'

export default function CustomToggleGroup() {
  const [textFormatting, setTextFormatting] = useState<string[]>([])

  return (
    <div className="space-y-4">
      <h2 className="text-lg font-semibold">テキストフォーマット</h2>
      <ToggleGroup
        type="multiple"
        value={textFormatting}
        onValueChange={setTextFormatting}
        className="flex justify-center"
      >
        <ToggleGroupItem value="bold" aria-label="太字">
          <Bold className="h-4 w-4" />
        </ToggleGroupItem>
        <ToggleGroupItem value="italic" aria-label="斜体">
          <Italic className="h-4 w-4" />
        </ToggleGroupItem>
        <ToggleGroupItem value="underline" aria-label="下線">
          <Underline className="h-4 w-4" />
        </ToggleGroupItem>
      </ToggleGroup>
      <p className="text-sm text-gray-500">
        適用されたフォーマット: {textFormatting.join(", ") || "なし"}
      </p>
      <div className="p-4 border rounded">
        <p
          className={`
            ${textFormatting.includes("bold") ? "font-bold" : ""}
            ${textFormatting.includes("italic") ? "italic" : ""}
            ${textFormatting.includes("underline") ? "underline" : ""}
          `}
        >
          サンプルテキスト
        </p>
      </div>
    </div>
  )
}


 
 ```

# スマホ型状態管理(slider,switch)
こちらはスマホでよく見る選択です。Sliderと呼ばれています。
![newlog.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/2505a51d-9695-d979-4c55-5474ee375711.gif)
* min={0},max={100},step={1}で大きさを変えます。
* value={[volume]}とonValueChangeで管理します。
         
 ```typescript
 "use client"

import { useState } from "react"
import { Slider } from "@/components/ui/slider"
import { Label } from "@/components/ui/label"

export default function CustomSlider() {
  const [volume, setVolume] = useState(50)

  return (
    <div className="space-y-4">
      <h2 className="text-lg font-semibold">音量調整</h2>
      <div className="space-y-2">
        <Label htmlFor="volume-slider">音量: {volume}%</Label>
        <Slider
          id="volume-slider"
          min={0}
          max={100}
          step={1}
          value={[volume]}
          onValueChange={(value) => setVolume(value[0])}
          className="w-[300px]"
        />
      </div>
      <div className="h-24 w-24 rounded-full bg-gradient-to-br from-blue-400 to-blue-700 flex items-center justify-center">
        <span className="text-white text-2xl font-bold">{volume}%</span>
      </div>
    </div>
  )
}

 ```
こちらはSwitchです。状態を!で反転するだけなので簡単です。

![スクリーンショット 2024-12-11 18.12.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/856039b3-b27c-b9d5-48e4-1d745bbce7c4.png)

 ```typescript
 "use client"

import { useState } from "react"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { Moon, Sun } from 'lucide-react'

export default function CustomSwitch() {
  const [isDarkMode, setIsDarkMode] = useState(false)
  const [isNotificationsEnabled, setIsNotificationsEnabled] = useState(true)

  return (
    <div className="space-y-6">
      <h2 className="text-lg font-semibold">設定</h2>
      
      {/* ダークモードスイッチ */}
      <div className="flex items-center justify-between">
        <div className="space-y-0.5">
          <Label htmlFor="dark-mode">ダークモード</Label>
          <p className="text-sm text-muted-foreground">
            画面の明るさを調整します
          </p>
        </div>
        <div className="flex items-center space-x-2">
          <Sun className="h-4 w-4" />
          <Switch
            id="dark-mode"
            checked={isDarkMode}
            onCheckedChange={setIsDarkMode}
          />
          <Moon className="h-4 w-4" />
        </div>
      </div>

      {/* 通知スイッチ */}
      <div className="flex items-center space-x-2">
        <Switch
          id="notifications"
          checked={isNotificationsEnabled}
          onCheckedChange={setIsNotificationsEnabled}
        />
        <Label htmlFor="notifications">通知を有効にする</Label>
      </div>

      {/* 設定状態の表示 */}
      <div className="p-4 border rounded">
        <p>現在の設定:</p>
        <ul className="list-disc list-inside">
          <li>ダークモード: {isDarkMode ? "オン" : "オフ"}</li>
          <li>通知: {isNotificationsEnabled ? "有効" : "無効"}</li>
        </ul>
      </div>
    </div>
  )
}
 ```

# 警告表示(Toast,alert)

![newlog.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/fa1b8a44-2379-624a-8aeb-69a51e87cc52.gif)


何かの操作を完了した時に、表示させるカードのようなもので、数秒後にフェードアウトします。

toastはuseToastで管理し、出したい画面でToastコンポーネントをおきます。
 ```typescript:components/toast-demo.tsx
 "use client"

import { Button } from "@/components/ui/button"
import { useToast } from "@/components/ui/use-toast"
import { ToastAction } from "@/components/ui/toast"

export function ToastDemo() {
  const { toast } = useToast()

  const showDefaultToast = () => {
    toast({
      title: "デフォルトトースト",
      description: "これは基本的なトーストメッセージです。",
    })
  }

  const showSuccessToast = () => {
    toast({
      title: "成功！",
      description: "操作が正常に完了しました。",
      variant: "default",
      className: "bg-green-500",
    })
  }

  const showErrorToast = () => {
    toast({
      title: "エラー",
      description: "問題が発生しました。もう一度お試しください。",
      variant: "destructive",
    })
  }

  const showActionToast = () => {
    toast({
      title: "更新があります",
      description: "新しいバージョンが利用可能です。",
      action: <ToastAction altText="アップグレード">アップグレード</ToastAction>,
    })
  }

  return (
    <div className="flex flex-col items-center space-y-4">
      <Button onClick={showDefaultToast}>デフォルトトースト</Button>
      <Button onClick={showSuccessToast}>成功トースト</Button>
      <Button onClick={showErrorToast}>エラートースト</Button>
      <Button onClick={showActionToast}>アクション付きトースト</Button>
    </div>
  )
}

 ```

こちらを出したい画面(ホーム画面)とします。
 ```typescript:app/layout.tsx
 import "./globals.css"
import type { Metadata } from "next"
import { Inter } from 'next/font/google'
import { Toaster } from "@/components/ui/toaster"

const inter = Inter({ subsets: ["latin"] })

export const metadata: Metadata = {
  title: "トーストデモ",
  description: "さまざまなトーストのバリエーションを表示するデモ",
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="ja">
      <body className={inter.className}>
        {children}
        <Toaster />
      </body>
    </html>
  )
}


 ```

 

こちらは簡単な警告のカードが作れます。

![スクリーンショット 2024-12-12 2.47.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/e3493dc8-2658-76d1-1c20-eb4c4e6f13bb.png)

 ```typescript
 import { AlertCircle } from "lucide-react"

import {
  Alert,
  AlertDescription,
  AlertTitle,
} from "@/components/ui/alert"

export function AlertDestructive() {
  return (
    <Alert variant="destructive">
      <AlertCircle className="h-4 w-4" />
      <AlertTitle>Error</AlertTitle>
      <AlertDescription>
        Your session has expired. Please log in again.
      </AlertDescription>
    </Alert>
  )
}

 ```



 # たまに見かけるもの(随時追加予定)
 
## skelton
読み込み中の画面を出します。設定したものの灰色の状態が続きます。

## calendar
fullcarendarよりかはできることは少ないですが、あれはエラーが多くて...
シンプルで便利かと思います。

次回はこれらを利用したよく使われるものをご紹介していきます！