メインコンテンツまでスキップ

実践演習:ゼロから Button コンポーネントを作る

この章で学ぶこと

これまで学んだ技術を 1 つずつ積み上げて、Button コンポーネントをゼロから完成させます。

Step 1: 素のボタン

Step 2: + Tailwind でスタイリング(デザイントークン) ← Tailwind の章・デザイントークンの章

Step 3: + cva でバリアント ← cva の章

Step 4: + cn でクラス結合 / ...props(ref も透過) ← cn の章・ref の章

Step 5: + asChild(Slot) ← Slot の章

Step 6: + TypeScript の型 ← TypeScript の章

完成(完成形の章の Button)

Step 1: 最もシンプルなボタン

// src/components/ui/button.tsx
function Button({ children }) {
return <button>{children}</button>
}

export { Button }
<Button>Click me</Button>
備考

この段階ではエディタに「暗黙の any」の型エラー(TS7031)が表示されますが、想定どおりです。型は Step 6 で付けます。ブラウザでの動作確認は問題なく行えます。

Step 2: Tailwind でスタイリング(Tailwind の章デザイントークンの章

デザイントークンの章で学んだデザイントークン(bg-primary など)を使います。

function Button({ children }) {
return (
<button className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50">
{children}
</button>
)
}

export { Button }
備考

この時点ではクラスが長く、別の見た目(赤いボタンなど)を増やすのが大変です。これが cva を導入する理由です。

Step 3: cva でバリアント管理(cva の章

cva でバリアントを宣言的にまとめます。色はすべてデザイントークンです。

// src/components/ui/button.tsx
import { cva } from "class-variance-authority"

const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white 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-9 px-4 py-2",
sm: "h-8 px-3",
lg: "h-10 px-6",
icon: "size-9",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)

function Button({ children, variant, size }) {
return <button className={buttonVariants({ variant, size })}>{children}</button>
}

export { Button, buttonVariants }
<Button>Default</Button>
<Button variant="destructive">削除</Button>
<Button variant="outline" size="lg">大きい枠線ボタン</Button>
備考

この後の Step では、上の buttonVariants の定義は変わりません。変化するのは Button 関数の部分だけなので、そこに注目してください。

Step 4: cn でクラス結合 + ...props(cn の章ref の章

cnclassName の上書きを安全にし、...props で残りの属性をまとめて受け取ります。

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

function Button({ className, variant, size, ...props }) {
return (
<button className={cn(buttonVariants({ variant, size, className }))} {...props} />
)
}

...props<button> に展開したことで、onClickdisabled、そして ref も透過的に渡るようになりました。React 19 では ref も普通の prop なので、これだけで動きます(ref の章)。

function FocusExample() {
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => buttonRef.current?.focus(), [])
return <Button ref={buttonRef}>自動でフォーカス</Button>
}
<Button className="mt-4 w-full">余白と幅を追加</Button>

Step 5: asChild パターン(Slot の章

radix-uiSlot で、要素を差し替えられるようにします。

import { Slot } from "radix-ui"

function Button({ className, variant, size, asChild = false, ...props }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
<Button asChild>
<a href="/profile">プロフィール</a>
</Button>

Step 6: TypeScript の型を付けて完成(TypeScript の章

最後に型を付けます。これが完成形の章で見た完成形です。

// src/components/ui/button.tsx
import * as React from "react"
import { Slot } from "radix-ui"
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-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white 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-9 px-4 py-2",
sm: "h-8 px-3",
lg: "h-10 px-6",
icon: "size-9",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)

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

function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}

export { Button, buttonVariants }

完成した Button をテストする

// src/App.tsx
import { Button } from "@/components/ui/button"

function App() {
return (
<div className="space-y-6 p-8">
<section className="space-x-2">
<h2 className="mb-2 text-lg font-semibold">バリアント</h2>
<Button variant="default">Default</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
</section>

<section className="space-x-2">
<h2 className="mb-2 text-lg font-semibold">サイズ</h2>
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon">🔍</Button>
</section>

<section className="space-x-2">
<h2 className="mb-2 text-lg font-semibold">カスタム / asChild / 無効</h2>
<Button className="w-full">幅いっぱい</Button>
<Button variant="outline" asChild>
<a href="https://example.com" target="_blank" rel="noopener">
リンクになるボタン
</a>
</Button>
<Button disabled>無効</Button>
</section>
</div>
)
}

export default App
備考

bg-primary などの色が表示されるには、デザイントークンの章のデザイントークンが src/index.css に定義されている必要があります(npx shadcn@latest init を使った場合は自動で入っています)。

演習課題

課題1: 新しいバリアントを追加する

success(緑系)バリアントを追加してみましょう。理想は、デザイントークンの章のやり方で --success トークンを定義し、bg-success text-success-foreground を使うことです。

解答例

まず src/index.css にトークンを定義し、@theme inline で Tailwind に登録します(デザイントークンの章の 3 段階)。

:root {
--success: oklch(0.6 0.13 160);
--success-foreground: oklch(0.985 0 0);
}
.dark {
--success: oklch(0.55 0.13 160);
--success-foreground: oklch(0.985 0 0);
}

@theme inline {
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
}

次に buttonVariantsvariant に 1 行足します。

success: "bg-success text-success-foreground hover:bg-success/90",
<Button variant="success">保存しました</Button>

ポイントは @theme inline の登録です。これがないと bg-success クラスが生成されません。

課題2: 新しいサイズを追加する

xs(とても小さい)サイズを追加してみましょう。

解答例

buttonVariantssize に 1 行足すだけです。

size: {
xs: "h-7 px-2 text-xs",
sm: "h-8 px-3",
default: "h-9 px-4 py-2",
lg: "h-10 px-6",
icon: "size-9",
},
<Button size="xs">とても小さい</Button>

h-7(28px)/ px-2(8px)/ text-xs で、sm より1回り小さくしています。

課題3: ローディング状態を追加する

isLoading prop を追加し、ローディング中はスピナーを表示してみましょう。

解答例

Step 6 で完成させた Button に、次の 4 点を足します。スピナーには shadcn/ui の標準アイコン lucide-reactLoader2(回転アイコン)を使うので、入っていなければ追加します。

npm install lucide-react

① 型に isLoading を追加する

type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
isLoading?: boolean // ← 追加
}

children を分割代入で取り出す

スピナーを children の前に置きたいので、これまで ...props に含めていた children を個別に受け取ります。

function Button({
className, variant, size, asChild = false,
isLoading = false, // ← 追加
children, // ← ...props から取り出す
...props
}: ButtonProps) {

disabledisLoading に連動させる

ローディング中は二重送信を防ぐため、自動で disabled にします。{...props} より後に書くことで、呼び出し側の disabledisLoading を OR 上書きできます(JSX の props は後に書いたものが勝つため、逆順だと {...props} に上書きされてしまいます)。

<Comp {...props} disabled={isLoading || props.disabled}>

④ スピナーを children の前に置く

{isLoading && <Loader2 className="mr-2 size-4 animate-spin" />}
{children}

以上をまとめた完成形です(コメントの番号が上の各ステップに対応します)。

import { Loader2 } from "lucide-react"

type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
isLoading?: boolean // ①
}

function Button({
className, variant, size, asChild = false,
isLoading = false, // ①
children, // ②
...props
}: ButtonProps) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
disabled={isLoading || props.disabled} // ③ ローディング中は無効化
>
{isLoading && <Loader2 className="mr-2 size-4 animate-spin" />} {/* ④ スピナー */}
{children}
</Comp>
)
}
<Button isLoading>送信中…</Button>
警告

asChildisLoading を同時に使うと、Slot が要求する「単一の子要素」の制約に反します(スピナー + children で 2 要素になるため)。両立させたい場合は、子要素側にスピナーを含める設計が必要です。

この章のまとめ

ゼロから次の順で Button を組み立てました。

  1. 素のボタン → 2. Tailwind(トークン)→ 3. cva → 4. cn と ...props(ref 透過)→ 5. Slot(asChild)→ 6. 型

forwardRef を使わずに、React 19 のシンプルな書き方で完成しました。

次章では、ここで身につけたパターンを、他のコンポーネントへ応用します。