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

Radix UI の Slot:asChild パターンと要素の置換

この章で学ぶこと

radix-uiSlot と、asChild パターンを学びます。見た目(スタイル)を保ったまま、レンダリングされる HTML 要素を差し替える方法です。これは、コンポーネントを柔軟に組み合わせる「合成(composition)」というデザインシステムの重要な考え方につながります。

問題:セマンティック HTML とスタイルの両立

Web では、意味に合った HTML 要素(セマンティック HTML)を使うことが大切です。ページ遷移は <a>、送信は <button>、というように。

ところが、「Button のデザインを当てたリンク」を作ろうとすると、どれも問題が出ます。

// ❌ <a> に Button のクラスを手書き → Button の変更に追従できない
<a href="/profile" className="inline-flex items-center rounded-md bg-primary ...">
プロフィール
</a>

// ❌ Button の中に <a> → <button> の中に <a> は無効な HTML
<Button><a href="/profile">プロフィール</a></Button>

// ❌ Button に href を生やす → あらゆる要素の対応を Button が背負うことになる
<Button href="/profile">プロフィール</Button>

解決策:asChild パターン

asChild は、スタイルを保ったまま、レンダリングする要素を「子要素」に委ねる仕組みです。

// ✅ asChild:見た目は Button、実体は <a>
<Button variant="outline" asChild>
<a href="/profile">プロフィール</a>
</Button>

// 出力される HTML
<a href="/profile" class="inline-flex items-center rounded-md border ...">
プロフィール
</a>

Slot の仕組み

asChild の正体が、radix-uiSlot です。Slot は「自分自身は描画せず、受け取った props を子要素に移し替える運び屋」です。

<Button asChild className="..." ref={ref}>
<a href="/profile">プロフィール</a>
</Button>
↓ Button 内部で Comp = Slot.Root
<Slot.Root className="..." ref={ref}>
<a href="/profile">プロフィール</a>
</Slot.Root>
↓ Slot がやること
1. Slot 自身は何も描画しない
2. 親から渡された props を受け取る
3. 単一の子要素を見つける
4. props を子要素にマージする
5. 子要素だけを描画する

<a href="/profile" class="..." ref={ref}>プロフィール</a>

props のマージルール

Slot は、親の props と子要素の props を賢くマージします。

props の種類マージ方法
className両方を結合(スペース区切り)
styleオブジェクトを統合
onClick などのハンドラ両方を順番に呼ぶ(子 → 親の順)
その他子要素の値を優先
ref子要素に転送(React 19 でもそのまま動く)
<Slot.Root className="parent" onClick={parentHandler}>
<a className="child" onClick={childHandler} href="/link">クリック</a>
</Slot.Root>

// 結果
<a
className="parent child"
onClick={(e) => { childHandler(e); parentHandler(e) }} // 子要素のハンドラが先、親(Slot 側)のハンドラが後
href="/link"
>
クリック
</a>

実践:Slot を使ってみる

Step 1: 基本的な Slot

radix-ui から Slot をインポートし、Slot.Root を使います。

import { Slot } from "radix-ui"

function Card({ asChild, children, ...props }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp className="rounded-lg bg-card p-4 shadow" {...props}>
{children}
</Comp>
)
}

// 通常(div として描画)
<Card>これはカードです</Card>
// => <div class="rounded-lg bg-card p-4 shadow">これはカードです</div>

// asChild(article として描画)
<Card asChild>
<article>これは記事カードです</article>
</Card>
// => <article class="rounded-lg bg-card p-4 shadow">これは記事カードです</article>

Step 2: リンクとしての Button

import { Button } from "@/components/ui/button"

function Navigation() {
return (
<nav>
{/* 普通のボタン */}
<Button onClick={() => console.log("clicked")}>クリック</Button>

{/* リンクとしてのボタン */}
<Button variant="outline" asChild>
<a href="/about">私たちについて</a>
</Button>
</nav>
)
}

Step 3: ref の転送

Slotref も子要素へ転送します。

function FocusableLink() {
const linkRef = useRef<HTMLAnchorElement>(null)

useEffect(() => {
linkRef.current?.focus()
}, [])

return (
<Button asChild>
<a href="/focused" ref={linkRef}>自動でフォーカスされます</a>
</Button>
)
}

Button(置換前の要素)の ref 型は HTMLButtonElement のままなので、置換先要素の ref は子要素側に書きます。親側に渡しても Slot は子へ転送しますが、型エラーになります。

asChild がデザインシステムにもたらす利点

asChild は、デザインシステムの「合成」を支える重要なパターンです。

1. 一貫したデザイン

<Button>普通のボタン</Button>
<Button asChild><a href="/link">リンクボタン</a></Button>
<Button asChild><Link href="/next">ルーターのリンク</Link></Button>

すべて同じ Button デザインで、中身(要素)だけが違います。

2. セマンティック HTML の維持

見た目は Button のまま、意味的には正しい <a> を使えます。SEO やアクセシビリティの面で正しいマークアップになります。

3. 責務の分離

Button は「見た目」だけを担当し、「何の要素か」は利用者が決めます。これは「単一責任」というデザインシステムの原則そのものです。

4. 拡張性

新しいリンク部品(Next.js の Link、React Router の Link など)が来ても、Button 側の変更は不要です。

Button での asChild

完成形の章の Button の、この 1 行がポイントでした。

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}
/>
)
}
asChild = false(既定) asChild = true
↓ ↓
Comp = "button" Comp = Slot.Root
↓ ↓
<button class="..."> <a class="...">(子要素に置き換わる)

注意点

子要素は 1 つだけ

Slot は単一の子要素しか受け取れません。

// ❌ エラー(子が 2 つ)
<Button asChild>
<span>アイコン</span>
<span>テキスト</span>
</Button>

// ✅ OK(1 つの要素でまとめる)
<Button asChild>
<a href="/link">
<span>アイコン</span>
<span>テキスト</span>
</a>
</Button>

子要素は React 要素であること

// ❌ エラー(ただのテキスト)
<Button asChild>ただのテキスト</Button>

// ✅ OK
<Button asChild>
<span>ただのテキスト</span>
</Button>

この章のまとめ

  • asChild は、スタイルを保ったまま要素を差し替える仕組み
  • その正体は radix-uiSlotSlot.Root を使う)
  • props は賢くマージされる(className 結合・ハンドラ連鎖・ref 転送)
  • セマンティック HTML とデザインを両立できる = **合成(composition)**の実現
  • 子要素は単一の React 要素であること

次章では、ここまでのコードを支える TypeScript の型について学びます。