🎨

shadcn/ui の Button コンポーネントから学ぶUI実装パターン

に公開

はじめに:再利用可能で柔軟なUIコンポーネントの設計思想

現代のフロントエンド開発では、一貫性のあるデザインシステムを維持しつつ、多様なユースケースに対応できる柔軟なUIコンポーネントが求められます。

shadcn/uiButton コンポーネントは、この課題に対する解決先として非常に有効なコンポーネントとなっています。

本稿では、Button.tsx の実装を深掘りすることで、その背景にある設計思想と、それを実現するコア技術について体系的に解説します。単なるコード解説に留まらず、なぜこのような実装が採用されているのかを理解することで、自身のコンポーネント設計に応用できる知識を習得することをめざします!


1. 完成形:Button.tsxの全コードと利用例

まず、本稿で解説するコンポーネントの完成形と、その基本的な使い方を確認します。

1-1. components/ui/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 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline:
          "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
        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",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }

1-2. 利用例

このコンポーネントは、props を通じてその外観や挙動を柔軟に制御できます。以下に主要なユースケースを示します。

基本的な使い方

最もシンプルな形でコンポーネントを呼び出します。defaultVariantsで定義されたスタイルが適用されます。

<Button>Default Button</Button>

バリアントの活用

variantsize プロパティを指定することで、あらかじめ定義されたスタイルセットを簡単に切り替えることができます。

<Button variant="destructive" size="lg">Delete</Button>

className によるスタイルの上書き

className プロパティにTailwind CSSのクラスを渡すことで、コンポーネントの基本スタイルを安全に上書き・拡張できます。内部で cn 関数がクラス名の競合を解決するため、意図しないスタイルの崩れが起こりにくくなっています。

<Button variant="default" className="bg-green-500 hover:bg-green-600 p-8">
  Override Styles
</Button>

asChild による要素の置換

asChild プロパティは、見た目を維持しつつ、レンダリングされるHTML要素を子要素に委譲する強力な機能です。これにより、例えばセマンティックな <a> タグにボタンのスタイルを適用できます。

<Button variant="outline" asChild>
  <a href="/profile">View Profile</a>
</Button>

ref によるDOMへの直接アクセス

ref プロパティを渡すことで、コンポーネントの外から内部の <button> DOM要素を直接操作できます。これにより、フォーカス管理やアニメーション制御など、より高度なユースケースに対応できます。

import { Button } from "@/components/ui/button";
import { useRef, useEffect } from "react";

function FocusButtonExample() {
  const buttonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    // コンポーネントマウント時にボタンにフォーカスを当てる
    buttonRef.current?.focus();
  }, []);

  return (
    <Button ref={buttonRef}>I am focused on mount</Button>
  );
}

2. 主要な構成要素:4つのコア技術

Button.tsx の実装は、主に以下の4つの技術的な要素によって支えられています。

  1. cva (class-variance-authority)
    コンポーネントのスタイルバリアント(種類、サイズなど)を宣言的に管理します。
  2. cn (clsx + tailwind-merge)
    複数のCSSクラス名を安全に結合し、Tailwind CSSのクラス競合を自動的に解決します。
  3. @radix-ui/react-slot
    スタイルを維持しつつ、レンダリングするHTML要素を子要素に置き換えることを可能にします。
  4. React.forwardRef
    親コンポーネントから子コンポーネント内のDOM要素への ref 参照を可能にします。

以降のセクションで、これらの技術がそれぞれどのような課題を解決し、どのように機能するのかを詳述します。


3. 詳細解説:各技術の深掘り

3-1. cva: 宣言的なスタイリングバリアントの管理

コンポーネントが複数の見た目のバリエーションを持つ場合、if 文や三項演算子でクラス名を分岐させる方法は、組み合わせが増えるほど複雑化し、保守性を低下させます。cva はこの問題を解決するためのライブラリです。

cva の構造
cva 関数は、第一引数に全バリアント共通のベースクラス、第二引数に設定オブジェクトを取ります。

  • variants: コンポーネントが持つバリエーションの型(例:variant, size)と、それぞれの選択肢に対応するクラスを定義します。
  • defaultVariants: props でバリアントの指定がない場合に適用されるデフォルト値を定義します。
  • compoundVariants: 複数のバリアントが同時に指定された場合にのみ適用される、より複雑なスタイルルールを定義できます。

このアプローチにより、スタイルの定義がコンポーネントのロジックから分離され、宣言的で可読性の高いコードベースが実現します。また、VariantProps<typeof buttonVariants> のように cva の定義から型を推論できるため、TypeScriptによる型安全性の恩恵も受けられます。

3-2. cn: 安全なクラス名のマージ

cn は、clsxtailwind-merge という2つのライブラリを組み合わせたユーティリティ関数です。再利用可能なコンポーネントにおいて、デフォルトスタイルと外部から注入されるカスタムスタイルを安全に共存させるために不可欠です。

処理プロセス

  1. 結合 (clsx): cn 関数に渡された複数の引数(文字列、オブジェクト配列)を、clsx が一つのクラス名文字列に結合します。条件付きでクラスを適用するロジックもここで処理されます。
  2. 競合解決 (tailwind-merge): clsx が生成した文字列を tailwind-merge が解析し、Tailwind CSSのルールに基づいて矛盾するクラスを解決します。

競合解決の例

  • cn('p-2', 'p-4')'p-4' (後から指定された同一プロパティが優先)
  • cn('p-4', 'px-6')'py-4 px-6' (より具体的な個別指定が優先)

この機能により、コンポーネント利用者は className プロパティを通じて、内部のスタイルを意図通り、かつ安全に上書きできます。

3-3. @radix-ui/react-slot: スタイルを維持しつつ要素を置換する

コンポーネントのデザインシステムを適用しつつ、HTMLのセマンティックな構造を維持することは重要な課題です。例えば、「見た目はボタンだが、機能的にはリンク」である要素を実装したい場合、単純な実装では課題が生じます。@radix-ui/react-slot はこの課題を解決します。

asChild プロパティの役割
この機能のスイッチとなるのが asChild プロパティです。以下のコードで考えてみましょう。

<Button variant="outline" asChild>
  <a href="/profile">View Profile</a>
</Button>

asChild={true} が指定されると、Button コンポーネント内部の const Comp = asChild ? Slot : "button" というロジックにより、レンダリングする要素が通常の <button> から <Slot> コンポーネントに切り替わります。

Slotの振る舞い
Slot は「透明なプロパティの運び屋」のように振る舞います。

  1. Slot自身はDOM要素を生成しません。 画面上に見えるDOMとしては存在しない、まさに「透明な」コンポーネントです。
  2. 親であるButtonコンポーネントから、計算済みのclassNameやイベントハンドラなど、すべてのプロパティを受け取ります。
  3. 唯一の子要素(この場合は <a> タグ)を特定します。
  4. Buttonから受け取ったプロパティを、子要素が元々持っているプロパティと賢く**マージ(合体)**させます。例えば、<a> タグが持つ href はそのまま維持し、Button から渡された className<a> タグに適用します。
  5. 最終的に、プロパティがマージされた子要素のみをレンダリングします。

最終的なHTML出力
このプロセスを経ることで、最終的にブラウザでレンダリングされるHTMLは以下のようになります。

<a href="/profile" class="inline-flex items-center justify-center ...">
  View Profile
</a>

<a> タグが、Button コンポーネントのスタイルを完全にまとった状態でレンダリングされました。

この仕組みにより、開発者はコンポーネントの再利用性と、アクセシビリティやSEOに配慮した適切なHTMLセマンティクスの両立が可能になります。

3-4. React.forwardRef: refの透過的な転送

ref は、DOMノードに直接アクセスするための仕組みで、フォーカス管理や要素寸法の取得などに用いられます。しかし、Reactの関数コンポーネントはデフォルトで ref をプロパティとして受け取れません。

React.forwardRef は、コンポーネントをラップすることで、親から渡された ref を受け取り、それをコンポーネント内部の特定のDOM要素に「転送」する機能を提供します。これにより、Button コンポーネントの利用者は、その内部の <button> DOM要素に直接アクセスできるようになり、コンポーネントの利用範囲が広がります。


4. 総合演習:すべてが連携する仕組み

個別に解説した技術が、Button コンポーネントのレンダリング部分でどのように連携するのかを見ていきます。

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    // 1. レンダリング要素の決定
    const Comp = asChild ? Slot : "button";

    return (
      <Comp
        // 2. クラス名の生成とマージ
        className={cn(buttonVariants({ variant, size, className }))}
        // 3. refの転送
        ref={ref}
        {...props}
      />
    );
  }
);

ユーザーが <Button variant="destructive" className="p-6 m-2" /> のように呼び出した際のデータフローは以下のようになります。

  1. buttonVariants 関数が呼び出され、variantsize に基づくデフォルトクラスと、引数として渡された className を含むクラス群を生成します。
  2. 生成されたクラス群が cn 関数に渡され、Tailwind CSSのルールに基づきクラスの競合が解決されます(例:px-4p-6 があれば後者が優先)。
  3. 最終的に矛盾のないクリーンな className 文字列が生成され、Comp(この場合は <button>)に適用されます。
  4. 同時に、親から渡された refComp に転送され、...props によってその他のHTML属性もすべて適用されます。

おわりに:この設計から学べること

Button.tsx の実装は、現代的なReactコンポーネント設計の優れた実践例です。cva による宣言的なスタイル管理、cn による安全なカスタマイズ性、Slot によるセマンティクスの維持、そして forwardRef による高度なDOM操作への対応。これらの要素が組み合わさることで、再利用性、拡張性、保守性、そして型安全性を高いレベルで満たすコンポーネントが実現されています。

ここで解説した設計パターンは、shadcn/ui の多くのコンポーネントに共通する思想です。この理解を足がかりに、他のコンポーネントの実装を読み解くことで、より深い知見を得ることができるでしょう。

GitHubで編集を提案

Discussion