Radix UI の Slot:asChild パターンと要素の置換
この章で学ぶこと
radix-ui の Slot と、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-ui の Slot です。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 の転送
Slot は ref も子要素へ転送します。
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-uiのSlot(Slot.Rootを使う) - props は賢くマージされる(
className結合・ハンドラ連鎖・ref転送) - セマンティック HTML とデザインを両立できる = **合成(composition)**の実現
- 子要素は単一の React 要素であること
次章では、ここまでのコードを支える TypeScript の型について学びます。