1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qiita全国学生対抗戦Advent Calendar 2024

Day 10

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

Last updated at Posted at 2024-12-11

はじめに

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

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

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

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

全体の概要

shadcn-uiでは簡単に以下のものが作れます

  • 選択タブ

スクリーンショット 2024-12-11 15.20.37.png

  • Drawer
    スクリーンショット 2024-12-11 15.21.09.png

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

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

スクリーンショット 2024-12-11 15.34.12.png

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

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

何が優れているか

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

基本的な使い方

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

以下のものを打つとインストールできます。
package.json下で行なってください。

npx shadcn@latest init

コンポーネントを入れるときは、以下のように選択してください。
すると選択肢が出てきます。

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

独自のセレクタは

  • variant→視覚的なスタイルを定義する
  • disabaled→ボタンを押せないようにする

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

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

"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

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

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

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

"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

  • inputと違い、maxlengthなどの違いがあります。
  • カウントしたい時はuseStateで管理します。
"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

"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

"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

"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

  • min={0},max={100},step={1}で大きさを変えます。
  • value={[volume]}とonValueChangeで管理します。
"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

"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

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

toastはuseToastで管理し、出したい画面でToastコンポーネントをおきます。

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>
 )
}

こちらを出したい画面(ホーム画面)とします。

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

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よりかはできることは少ないですが、あれはエラーが多くて...
シンプルで便利かと思います。

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?