😬

shadcn/uiのButtonコンポーネントの仕組みを理解する!

に公開

現在、Next.jsを使って色々と開発しながら学習中ですヽ(`▽´)/
shadcn/uiは、コピー&ペーストで使えるReactコンポーネントライブラリです♪
この記事では、その中でも最も基本的な?Buttonコンポーネントのコードが何をしているかを、あとで見返しても分かるように残しておこうと思います!!

この記事で学べること

  • shadcn/uiのButtonコンポーネントの構造
  • class-variance-authority(CVA)の使い方
  • TypeScriptの型定義の読み方
  • Radix UIのSlotコンポーネントの活用方法

全体のコード

まず、Buttonコンポーネントの全体像を見てみましょうヽ(`▽´)/
shadcn.uiからnpxしてきたコードそのままです!これで各行何をしているか理解していきます!

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 hover:bg-primary/90",
        destructive:
          "bg-destructive text-white 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 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",
        "icon-sm": "size-8",
        "icon-lg": "size-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

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

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

export { Button, buttonVariants }

それでは、各部分を詳しく見ていきましょう。

1. インポート文の解説

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"

import * as React from "react"

Reactのすべての機能をReactという名前でインポートしています。React.ComponentPropsなどの型を使うために必要です。さすがにこの行の説明はいらないか、、、

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

Radix UIは、アクセシビリティ(使いやすさ)に配慮した UI コンポーネントライブラリです。shadcn/ui は、この Radix UI のコンポーネントをベースに、Tailwind CSS でスタイリングを追加して作られています。

ここで使っているSlotコンポーネントは、子要素に props を自動的にマージする特殊なコンポーネントです。これにより、Button コンポーネントを<button>タグだけでなく、<a>タグや他のコンポーネントとしても使えるようになります。

なぜSlotが必要なのか?

通常、Button コンポーネントは<button>タグとしてレンダリングされます。

<Button>クリック</Button>
// ↓ 実際にはこうなる
<button className="...">クリック</button>

しかし、リンクとして使いたい場合、<a>タグとしてレンダリングしたいですよね。Slotを使うと、以下のように書けるんです!

<Button asChild>
  <a href="/about">詳細を見る</a>
</Button>

この場合、<a>タグに Button のスタイルと props が自動的に適用されます。つまり、見た目は Button だけど、実際の HTML 要素は<a>タグになる、という柔軟な使い方ができるのです。

実際の動作

asChild={true}の場合は、

  • Slotコンポーネントが使われる
  • 子要素(<a>タグ)に Button のスタイルと props がマージされる
  • 結果として、<a>タグに Button の見た目が適用される

asChild={false}(デフォルト)の場合は、

  • 通常の<button>タグとしてレンダリングされる

import { cva, type VariantProps } from "class-variance-authority"

cvaは、条件付きでCSSクラスを適用するためのライブラリです。

  • cva: バリアント(バリエーション)を定義する関数
  • VariantProps: バリアントから型を自動生成する型

CVA を使うことで、variant="primary"size="large"のように、props に応じて異なるスタイルを適用できます。

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

cn関数は、Tailwind CSS のクラス名を安全にマージするためのユーティリティ関数です。
実装は以下のようになっています!

import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

clsxでクラス名を結合し、twMergeで Tailwind の競合するクラス(例:px-4px-6)を適切に解決します。

2. buttonVariants の定義

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: {
      // バリアントの定義
    },
    defaultVariants: {
      // デフォルト値の設定
    },
  }
);

ベースクラスの解説

最初の引数は、すべてのボタンに適用される基本スタイルです。Tailwind CSS のクラス名が並んでいますよねヽ(`▽´)/以下がついているCSSの内容です✨️

  • inline-flex: インラインの flexbox レイアウト
  • 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 アイコンはクリック不可
  • [&_svg:not([class*='size-'])]:size-4: サイズ指定のない SVG は 4×4 に
  • focus-visible:ring-[3px]: フォーカス時にリングを表示(アクセシビリティ)

variants オブジェクト

variantsオブジェクトでは、異なる見た目やサイズのバリエーションを定義します。

variant(見た目のバリエーション)

variant: {
  default: "bg-primary text-primary-foreground hover:bg-primary/90",
  destructive: "bg-destructive text-white hover:bg-destructive/90 ...",
  outline: "border bg-background shadow-xs hover:bg-accent ...",
  secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
  ghost: "hover:bg-accent hover:text-accent-foreground ...",
  link: "text-primary underline-offset-4 hover:underline",
}
  • default: 通常のプライマリボタン(青や緑など)
  • destructive: 危険な操作用(赤系)
  • outline: 枠線のみのボタン
  • secondary: セカンダリボタン(グレー系)
  • ghost: 背景なしのボタン
  • link: リンク風のボタン

size(サイズのバリエーション)

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",
  "icon-sm": "size-8",
  "icon-lg": "size-10",
}
  • default: 標準サイズ(高さ 36px)
  • sm: 小さいサイズ(高さ 32px)
  • lg: 大きいサイズ(高さ 40px)
  • icon: アイコンのみ用(正方形)
  • has-[>svg]:px-3: 子要素に SVG がある場合のパディング調整

defaultVariants

defaultVariants: {
  variant: "default",
  size: "default",
}

props を指定しなかった場合のデフォルト値を設定します。

3. Button コンポーネントの定義

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

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

型定義の解説

React.ComponentProps<"button"> &
  VariantProps<typeof buttonVariants> &
  {
    asChild?: boolean;
  }

この型定義は、交差型という TypeScriptの機能を使って、3 つの型を組み合わせているみたいです。&記号は「かつ」を意味し、すべての型のプロパティを合わせた型になりますね。

それぞれの型が持つプロパティ

まず、それぞれの型がどんなプロパティを含んでいるか見てみましょう!

1. React.ComponentProps<"button">
これは、通常の<button>要素が持つすべての props の型です。具体的には、

{
  onClick?: (event: MouseEvent) => void;
  disabled?: boolean;
  type?: "button" | "submit" | "reset";
  className?: string;
  id?: string;
  // ... その他多くのプロパティ
}

つまり、onClickdisabledtypeなど、HTML の<button>要素で使えるすべての属性が含まれています。

2. VariantProps<typeof buttonVariants>
これは、buttonVariantsから自動的に生成される型です。buttonVariantsで定義したvariantsizeの型が含まれます(^^)

{
  variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
  size?: "default" | "sm" | "lg" | "icon" | "icon-sm" | "icon-lg";
}

せっかくなんで、型定義の文法を詳しく見てみようヽ(`▽´)/

この型定義の文法を分解して詳しめに説明しときます。
あくまで、自分が、後で読み返してすぐに理解できるようの記事なので、、、

{
  variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
  size?: "default" | "sm" | "lg" | "icon" | "icon-sm" | "icon-lg";
}

1. { } - オブジェクト型リテラル
波括弧{ }は、オブジェクトの型を定義するときに使います。JavaScript のオブジェクトと同じ見た目ですが、TypeScript では型を表します。

2. variant? - プロパティ名とオプショナル記号

  • variantはプロパティ名です
  • ?は「オプショナル(省略可能)」を意味します
  • variant?は「variantプロパティはあってもなくてもいい」という意味です
// ✅ variant を指定してもいい
<Button variant="default">クリック</Button>

// ✅ variant を省略してもいい(デフォルト値が使われる)
<Button>クリック</Button>

3. "default" | "destructive" | ... - ユニオン型(Union Types)
|(パイプ記号)は「または」を意味し、ユニオン型を作ります。

  • "default" | "destructive" | "outline"は、「"default"または"destructive"または"outline"のいずれか」という意味です。
  • これらは文字列リテラル型と呼ばれ、特定の文字列のみを許可します!
// ✅ 有効な値
<Button variant="default">クリック</Button>
<Button variant="destructive">削除</Button>

// ❌ エラー!定義されていない値は使えない
<Button variant="primary">クリック</Button>  // エラー!

4. 全体の意味

variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";

この行は以下の意味です。

  • variantというプロパティは省略可能?
  • もし指定する場合は、"default""destructive""outline""secondary""ghost""link"いずれかでなければならないってことですね✨️

5. 他の型との比較

// 文字列リテラル型(特定の文字列のみ許可)
variant?: "default" | "destructive"

// 通常の文字列型(どんな文字列でも OK)
name?: string

// 数値型
count?: number

// 真偽値型
isActive?: boolean

文字列リテラル型を使うことで、間違った値を渡すことを防げるため、バグを減らすことができます。

CVA で定義したバリアントの選択肢が、自動的に型として使えるようになります。

3. { asChild?: boolean }
これは、asChildプロパティの型です!!

{
  asChild?: boolean;
}

3 つを組み合わせた結果

これら 3 つの型を&で組み合わせると、Button コンポーネントは以下のような型になります。

{
  // React.ComponentProps<"button">から
  onClick?: (event: MouseEvent) => void;
  disabled?: boolean;
  type?: "button" | "submit" | "reset";
  className?: string;
  // ... その他の button 要素のプロパティ

  // VariantProps<typeof buttonVariants>から
  variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
  size?: "default" | "sm" | "lg" | "icon" | "icon-sm" | "icon-lg";

  // { asChild?: boolean }から
  asChild?: boolean;
}

なぜ 3 つに分けているのか?

それぞれの型が異なる役割を持っているため、分けて定義しています!

  • React.ComponentProps<"button">: HTML の<button>要素としての機能(クリック、無効化など)
  • VariantProps<typeof buttonVariants>: 見た目のバリエーション(色、サイズなど)
  • { asChild?: boolean }: Button コンポーネント独自の機能

このように分けることで、型の再利用性が高まり、コードの保守性も向上します。

実際の使用例

この型定義により、以下のように使えます。

// ✅ すべて有効
<Button
  onClick={() => console.log("clicked")} // React.ComponentProps<"button">から
  disabled={true} // React.ComponentProps<"button">から
  variant="destructive" // VariantProps から
  size="lg" // VariantProps から
  asChild={false} // { asChild?: boolean }から
>
  削除
</Button>

パラメータのデフォルト値

variant = "default",
size = "default",
asChild = false,

props が渡されなかった場合のデフォルト値を設定しています。

asChildの仕組み

const Comp = asChild ? Slot : "button";

asChildtrueの場合、Slotコンポーネントを使用します。これにより、Button のスタイルを他の要素(<a>タグなど)に適用できます。

className のマージ

className={cn(buttonVariants({ variant, size, className }))}

この 1 行で、複数の CSS クラスを安全に結合しています。段階的に見てみましょう。

ステップ 1: buttonVariants({ variant, size, className })の動作

buttonVariantsは CVA で定義した関数で、variantsizeに応じて適切な CSS クラス名の文字列を返します。

具体例:

// variant="default", size="default"の場合
buttonVariants({ variant: "default", size: "default" });
// 戻り値: "inline-flex items-center justify-center gap-2 ... bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 ..."

さらに、classNameプロパティも渡すと、CVA はそれを追加のクラスとして扱います!

// ユーザーが className="w-full" を渡した場合
buttonVariants({
  variant: "default",
  size: "default",
  className: "w-full",
});
// 戻り値: "inline-flex items-center ... bg-primary ... w-full"

ステップ 2: cn()関数による安全なマージ

cn()関数は、clsxtwMergeを使ってクラス名を安全にマージします。

なぜcn()が必要なのか?

Tailwind CSS では、同じ種類のクラス(例:px-4px-6)を両方指定すると、後から読み込まれた方だけが適用されます。でも、px-4が残っていると無駄なコードなので気持ち悪いですよね!!

問題のある例:

// buttonVariants が "px-4" を返す
// ユーザーが className="px-6" を渡す
// 結果: "px-4 px-6" → px-6 だけが適用される(px-4 はコードとして残るが無視される)

cn()を使った解決:

cn("px-4", "px-6");
// 戻り値: "px-6" (競合するクラスを解決して、適切な方だけを残す)

実際の動作フロー

実際に使う場合の流れを見てみましょう!!

<Button variant="destructive" size="lg" className="w-full font-bold">
  削除
</Button>

1. buttonVariants()が呼ばれる

buttonVariants({
  variant: "destructive",
  size: "lg",
  className: "w-full font-bold",
});

この関数は、以下のクラスを返します!

  • ベースクラス: "inline-flex items-center justify-center ..."
  • variant="destructive"のクラス: "bg-destructive text-white hover:bg-destructive/90 ..."
  • size="lg"のクラス: "h-10 rounded-md px-6 ..."
  • ユーザー指定のクラス: "w-full font-bold"

これらが結合されて、長い文字列になります。

2. cn()が呼ばれる

cn("inline-flex items-center ... bg-destructive ... h-10 ... w-full font-bold");

cn()は以下の処理を行います!!

  • clsx()でクラス名を正規化(undefinednullを除去)
  • twMerge()で競合する Tailwind クラスを解決

3. 最終的なclassName

className =
  "inline-flex items-center justify-center gap-2 ... bg-destructive text-white ... h-10 rounded-md px-6 ... w-full font-bold";

このクラス名が<button>要素に適用され、適切なスタイルが表示されます。

なぜ 2 段階で処理するのか?

  1. buttonVariants(): CVA で定義したバリアントに応じたクラスを生成
  2. cn(): 生成されたクラスとユーザー指定のクラスを安全にマージ

この 2 段階に分けることで、

  • バリアントのクラス生成(CVA の役割)
  • クラスの安全なマージ(cnの役割)

が明確に分離され、コードの保守性が向上します。

data 属性

data-slot="button"
data-variant={variant}
data-size={size}

data 属性とは?

data-で始まる属性は、HTML 要素にカスタムデータを保存するための属性です。これらは、CSS や JavaScript で要素を識別したり、スタイリングや動作を制御したりするために使われます。

実際の HTML での見え方

Button コンポーネントがレンダリングされると、以下のような HTML が生成されます。

<button
  data-slot="button"
  data-variant="destructive"
  data-size="lg"
  class="inline-flex items-center ..."
>
  削除
</button>

それぞれの data 属性の役割

1. data-slot="button"

  • shadcn/ui の命名規則で、この要素が Button コンポーネントであることを示します
  • CSS や JavaScript で「Button コンポーネント」を特定するために使われます

2. data-variant={variant}

  • 現在のバリアント("default""destructive"など)を保存します
  • CSS で特定のバリアントだけにスタイルを適用する場合に便利です。
/* data-variant="destructive" のボタンだけに適用されるスタイル */
button[data-variant="destructive"] {
  /* カスタムスタイル */
}

3. data-size={size}

  • 現在のサイズ("default""sm""lg"など)を保存します
  • 同様に、CSS や JavaScript でサイズに応じた処理を行う場合に使えます

なぜ data 属性を使うのか?

  • CSS でのスタイリング: 特定のバリアントやサイズに応じたスタイルを適用できる
  • JavaScript での制御: 要素の種類や状態を識別して、適切な処理を実行できる
  • デバッグ: ブラウザの開発者ツールで、要素の状態を確認しやすい

スプレッド演算子

{...props}

スプレッド演算子とは?

{...props}は、**スプレッド演算子(Spread Operator)**という JavaScript/TypeScript の機能です。オブジェクトや配列の中身を「展開」するために使います。

基本的な動作

// 例:オブジェクトを展開する
const props = { onClick: () => {}, disabled: true, type: "button" };

// スプレッド演算子を使うと...
<button {...props} />
// これは以下と同じ意味:
<button onClick={() => {}} disabled={true} type="button" />

Button コンポーネントでの使用

Button コンポーネントでは、以下のように使われています。

function Button({ className, variant, size, asChild, ...props }) {
  // ... 中略 ...
  return (
    <Comp
      className={cn(buttonVariants({ variant, size, className }))}
      {...props} // ← ここでスプレッド演算子を使用
    />
  );
}

...propsに含まれるもの

...propsには、Button コンポーネントに渡された props のうち、明示的に取り出されていないものがすべて含まれます。

具体例:

<Button
  variant="destructive"
  size="lg"
  onClick={() => console.log("clicked")}
  disabled={true}
  type="submit"
  id="delete-button"
>
  削除
</Button>

この場合、、

  • variantsizeclassNameasChildは、関数の引数で明示的に取り出される
  • onClickdisabledtypeid...propsに含まれる
  • 結果として、{...props}は以下のようになる:
{
  onClick: () => console.log("clicked"),
  disabled: true,
  type: "submit",
  id: "delete-button"
}

なぜスプレッド演算子を使うのか?

スプレッド演算子を使うことで、Button コンポーネントで明示的に定義していない props も、そのまま<button>要素に渡すことができます。

メリット

  • <button>要素が持つすべての属性(onClickdisabledtypeidaria-labelなど)を自由に使える
  • Button コンポーネントのコードを簡潔に保てる
  • 将来、HTML の<button>要素に新しい属性が追加されても、自動的に対応できる

スプレッド演算子を使わない場合

もし{...props}を使わなければ、すべての props を明示的に定義する必要があります。

// ❌ 非効率的な方法
function Button({
  className,
  variant,
  size,
  asChild,
  onClick, // すべての props を明示的に定義する必要がある
  disabled,
  type,
  id,
  // ... 他にもたくさん
}) {
  // ...
}

この方法では、使いたい props の数だけ引数を増やす必要があり、コードが煩雑になります。

4. エクスポート

export { Button, buttonVariants };
  • Button: メインのコンポーネント
  • buttonVariants: 他のコンポーネントで再利用する場合にエクスポート

使用例

// 基本的な使い方
<Button>クリック</Button>

// バリアントを指定
<Button variant="destructive">削除</Button>

// サイズを指定
<Button size="lg">大きなボタン</Button>

// リンクとして使用
<Button asChild variant="link">
  <a href="/about">詳細を見る</a>
</Button>

// カスタムクラスを追加
<Button className="w-full">全幅ボタン</Button>

まとめ

というわけで、まとめると、shadcn/ui の Button コンポーネントは、以下の技術を組み合わせて作られていると思います!

  1. CVA(class-variance-authority): バリアントベースのスタイリング
  2. TypeScript: 型安全性の確保
  3. Radix UI: アクセシビリティと柔軟性
  4. Tailwind CSS: ユーティリティファーストのスタイリング

調べれば調べるほど、なるほど〜ってなることが多い今日このごろ、引き続き、頑張ります✨️

Discussion