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-4とpx-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;
// ... その他多くのプロパティ
}
つまり、onClick、disabled、typeなど、HTML の<button>要素で使えるすべての属性が含まれています。
2. VariantProps<typeof buttonVariants>
これは、buttonVariantsから自動的に生成される型です。buttonVariantsで定義したvariantとsizeの型が含まれます(^^)
{
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";
asChildがtrueの場合、Slotコンポーネントを使用します。これにより、Button のスタイルを他の要素(<a>タグなど)に適用できます。
className のマージ
className={cn(buttonVariants({ variant, size, className }))}
この 1 行で、複数の CSS クラスを安全に結合しています。段階的に見てみましょう。
ステップ 1: buttonVariants({ variant, size, className })の動作
buttonVariantsは CVA で定義した関数で、variantとsizeに応じて適切な 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()関数は、clsxとtwMergeを使ってクラス名を安全にマージします。
なぜcn()が必要なのか?
Tailwind CSS では、同じ種類のクラス(例:px-4とpx-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()でクラス名を正規化(undefinedやnullを除去) -
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 段階で処理するのか?
-
buttonVariants(): CVA で定義したバリアントに応じたクラスを生成 -
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>
この場合、、
-
variant、size、className、asChildは、関数の引数で明示的に取り出される -
onClick、disabled、type、idは...propsに含まれる - 結果として、
{...props}は以下のようになる:
{
onClick: () => console.log("clicked"),
disabled: true,
type: "submit",
id: "delete-button"
}
なぜスプレッド演算子を使うのか?
スプレッド演算子を使うことで、Button コンポーネントで明示的に定義していない props も、そのまま<button>要素に渡すことができます。
メリット
-
<button>要素が持つすべての属性(onClick、disabled、type、id、aria-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 コンポーネントは、以下の技術を組み合わせて作られていると思います!
- CVA(class-variance-authority): バリアントベースのスタイリング
- TypeScript: 型安全性の確保
- Radix UI: アクセシビリティと柔軟性
- Tailwind CSS: ユーティリティファーストのスタイリング
調べれば調べるほど、なるほど〜ってなることが多い今日このごろ、引き続き、頑張ります✨️
Discussion