コンポジション
Caroa UIの部品を正しい親子関係で組み合わせ、拡張可能な画面を作るためのガイド
概要
コンポジションは、小さな部品を正しい親子関係で組み合わせてUIを作る考え方です。Caroa UIでは、1つのコンポーネントに多数のpropsを足して画面全体を任せるのではなく、CardHeader、DialogFooter、FieldControlのような役割の明確な部品を組み合わせます。
構造をツリーで先に確認すると、必要なラッパーの欠落、不正なネスト、責務の大きすぎるvariantを防げます。この方針はshadcn/uiのComponent Compositionを参考に、Caroa UIのasChild、data-slot、Server / Clientエントリポイントへ合わせたものです。
最初に決めること
実装前に、次の順番で考えます。
- 目的に合う既存コンポーネントがあるか確認する
- 複合コンポーネントなら、親子構造をツリーで確認する
- 見た目だけの差は
variant、構造や役割の差は子要素の組み合わせで表す - HTML要素やルーターのLinkへ差し替える必要がある場合だけ
asChildを使う - インタラクションを含む最小の範囲だけClient Componentにする
基本原則
部品の役割を保つ
CardTitleはタイトル、DialogFooterはダイアログ下部のアクション、FieldControlは1つの入力コントロールを受け持ちます。見た目が似ていても、別の役割の部品で代用しません。
構造はchildrenで表す
タイトル、説明、本文、アクションを個別propsへ詰め込むより、公開されているサブコンポーネントをchildrenとして配置します。これにより、内容や並びを利用側で調整しながら、共通の余白とセマンティクスを保てます。
variantは構造を変えない
variantは、同じ責務とほぼ同じDOM構造のまま、色、密度、枠線などを切り替えるために使います。ヘッダーやフッターの有無、フォームの種類、操作フローまでvariantで切り替えないでください。
代表的な構成
Card
Card
├── CardHeader
│ ├── CardTitle
│ ├── CardDescription (任意)
│ └── CardAction (任意)
├── CardContent
└── CardFooter (任意)import {
Button,
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@caroainc/ui-components/server'
export function ProjectCard() {
return (
<Card>
<CardHeader>
<CardTitle>Webサイトリニューアル</CardTitle>
<CardDescription>最終更新: 9月2日</CardDescription>
<CardAction>
<Button variant="ghost" size="sm">詳細</Button>
</CardAction>
</CardHeader>
<CardContent>
<p>進行中のタスクは3件です。</p>
</CardContent>
<CardFooter>
<Button>開く</Button>
</CardFooter>
</Card>
)
}CardActionはCardHeader内に置きます。タイトルとアクションを同じ行で厳密に揃えたい場合は、CardHeader内にflexのラッパーを追加し、その中でグループ化してください。
Dialog
Dialog
├── DialogTrigger (任意、通常はasChild)
└── DialogContent
├── DialogHeader
│ ├── DialogTitle
│ └── DialogDescription (任意)
├── 本文
└── DialogFooter
├── DialogClose (任意、通常はasChild)
└── 実行ボタン'use client'
import { Button } from '@caroainc/ui-components/server'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@caroainc/ui-components/client'
export function DeleteProjectDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">削除を確認</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>プロジェクトを削除しますか</DialogTitle>
<DialogDescription>この操作は取り消せません。</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">キャンセル</Button>
</DialogClose>
<Button type="button" variant="destructive">削除</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}通常はDialogContentだけを置けば、Portal、Overlay、右上の閉じるボタンが内部で構成されます。独自実装が必要でない限り、DialogPortalやDialogOverlayを重ねて追加しません。
Field
Field
├── FieldLabel
├── FieldControl
│ └── 1つのInput / Textarea / CommandCombobox
├── FieldDescription (任意)
└── FieldError (任意)'use client'
import {
Field,
FieldControl,
FieldDescription,
FieldError,
FieldLabel,
} from '@caroainc/ui-components/client'
import { Input } from '@caroainc/ui-components/server'
export function EmailField({ errorMessage }: { errorMessage?: string }) {
return (
<Field>
<FieldLabel>メールアドレス</FieldLabel>
<FieldControl>
<Input type="email" aria-invalid={Boolean(errorMessage)} />
</FieldControl>
<FieldDescription>業務用のメールアドレスを入力してください。</FieldDescription>
<FieldError>{errorMessage}</FieldError>
</Field>
)
}FieldControlは常にRadix Slotで子へpropsを合成します。子は有効なReact要素を1つだけ渡してください。react-hook-formと連携する場合は、同じ目的のFormを使用します。
asChildとSlot
asChildは、Caroa UIが新しいDOM要素を追加せず、単一の子要素へclassName、data属性、イベント、アクセシビリティ属性を合成する仕組みです。
LinkをButtonの見た目にする
import { Button } from '@caroainc/ui-components/server'
<Button asChild>
<a href="/projects">プロジェクト一覧</a>
</Button>ネストしたbuttonを避ける
RadixのTriggerはデフォルトでbuttonを描画します。Buttonを子にする場合はasChildを指定し、buttonの中へbuttonを作らないようにします。
// Good: 最終DOMはbuttonが1つ
<DialogTrigger asChild>
<Button>開く</Button>
</DialogTrigger>
// Bad: buttonの中にbuttonが入る
<DialogTrigger>
<Button>開く</Button>
</DialogTrigger>asChildの制約
- 子要素は1つだけにする。配列、Fragment、複数要素は渡さない
- 最終要素の意味は子が決める。リンクは
href、操作はbuttonなど、適切なHTML要素を使う divへButtonの見た目を移しても、キーボード操作やbuttonの意味は自動では得られないButton asChildのloadingは無効状態を適用するが、Slotの単一子制約によりスピナーは描画しないasChildを重ねるほどpropsとイベントの由来が追いにくくなる。必要な境界だけで使う
data-slotで構造を確認する
DOMを描画するCaroa UIの部品には、data-caroa-uiと役割別のdata-slotが付きます。Compositionツリーと実DOMが一致しているかを、ブラウザの開発者ツールやテストで確認できます。
data-slot="card"
├── data-slot="card-header"
│ ├── data-slot="card-title"
│ └── data-slot="card-description"
├── data-slot="card-content"
└── data-slot="card-footer"限定的なスタイル調整が必要な場合も、構造を壊すwrapperを増やす前にslotを利用できます。
[data-caroa-ui][data-slot='dialog-content'] {
max-height: 85vh;
overflow-y: auto;
}DialogやSelectのルートはContextを提供するだけで、自前のDOMを描画しません。この場合、ルート名のslotが実DOMに現れないことがあります。dialog-contentやselect-triggerなど、実際に描画される部品を確認してください。
Server / Clientエントリポイント
Composition全体をClient Componentにする必要はありません。静的な外枠はServer Componentに保ち、状態やイベントを持つ小さな部品だけをClient Componentへ切り出します。
Server Page
└── Card (/server)
├── CardHeader (/server)
├── CardContent (/server)
└── ProjectActions ('use client'の小さな境界)
└── Dialog (/client)| 用途 | エントリポイント | 例 |
|---|---|---|
| 表示とスタイルだけ | /server | Button、Card、Badge、Input |
| 状態、Context、Radixの操作 | /client | Dialog、Tabs、Select、DropdownMenu、Field |
| react-hook-form連携 | /form | Form、FormField、FormControl |
asChildはDOMの合成方法を変えるだけで、Server / Client境界は変えません。クリックハンドラやローカルstateが必要な部分は'use client'のファイルへ置き、できるだけ小さな葉コンポーネントにします。
既存コンポーネントの選び方
| 作りたいもの | 推奨する組み合わせ |
|---|---|
| タイトル、本文、アクションを持つ面 | Card + CardHeader + CardContent + CardFooter |
| 確認や短い入力フロー | Dialog + DialogHeader + DialogFooter |
| ラベル付き入力 | Field + FieldLabel + FieldControl |
| react-hook-formの入力 | Form + FormField + FormItem + FormControl |
| 複数の関連ボタン | ButtonGroup + Button |
| タイトル、説明、操作を持つ行 | Item + ItemContent + ItemActions |
| 選択肢が多く検索が必要 | CommandCombobox |
| ページタイトルと右側アクション | PageHeader + actions prop |
既存部品で表現できる場合、似たラッパーを新しく作りません。複数画面で同じCompositionが繰り返され、構造と振る舞いを一括で保つ必要が出た時点で、プロダクト側の合成コンポーネントとして切り出します。
巨大variantを避ける
次のようなpropsが増え始めたら、variantではなくCompositionへ分けるサインです。
// Bad: 1つのコンポーネントが構造と業務ロジックを抱えている
<ProjectCard
variant="withOwnerAndActions"
title="Webサイトリニューアル"
description="進行中"
owner={owner}
showMenu
confirmBeforeDelete
/>// Good: 部品の役割と構造が見える
<Card>
<CardHeader>
<CardTitle>Webサイトリニューアル</CardTitle>
<CardDescription>進行中</CardDescription>
<CardAction>{menu}</CardAction>
</CardHeader>
<CardContent>{owner}</CardContent>
</Card>| variantが向く変更 | Compositionが向く変更 |
|---|---|
| 色、枠線、密度、サイズ | 子要素の追加、削除、並び替え |
| 同じ操作の強調度 | タイトル、本文、操作など役割の追加 |
| 同じDOM構造の見た目 | Context、状態、業務フローの追加 |
実装チェックリスト
- 既存コンポーネントで表現できないか確認した
- 複合コンポーネントの親子構造をツリーで確認した
- TriggerやCloseでbuttonが二重になっていない
-
asChildまたはSlotへ有効な子要素を1つだけ渡した - variantに構造や業務ロジックを詰め込んでいない
-
/server、/client、/formを責務に合わせて選んだ - Client境界を必要な範囲に限定した
-
data-slotと実DOMを確認した - 色や余白の追加が必要な場合も、Caroa UIのセマンティックトークンを使った