components について
components は、一貫性とアクセシビリティを備えた UI コンポーネントを設計するスキルです。複雑な動作にはヘッドレスプリミティブを用い、API、状態、スタイル、テストを扱います。
主な機能
- 01複雑な操作にプリミティブを選択
- 02操作状態を網羅的に定義
- 03制御・非制御 API を構成
- 04デザイントークンでスタイルを指定
- 05キーボード・画面幅・支援技術でテスト
活用シーン
- 01アクセシブルなダイアログやメニューを設計
- 02コンポーネントの状態網羅性を点検
- 03コンポーネント API とスタイルを標準化
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ paragonhq/ stealth-skills --skill componentsインストール後、Claude Code で/componentsを入力して読み込まれたか確認します。
詳細
コンポーネント
コンポーネントは契約です。どこでも同じように見え、どこでも同じように動作し、信頼するために中身を確認する必要がありません。生成されたコンポーネントのほとんどは、3つ目の条件を満たしません。
プリミティブか、自前で実装するか?
動作を伴わないものだけ自前で実装します。カード、バッジ、区切り線、統計値などです。フォーカス管理、キーボード操作、配置、ARIA のいずれかを伴うものには、ヘッドレスプリミティブを使います。
| 必要なもの | プリミティブ | 自前で実装しない理由 |
|---|---|---|
| ダイアログ、シート、ポップオーバー、メニュー、セレクト、ツールチップ、タブ、トグル、スライダー、コンボボックス | Base UI, Radix, React Aria, Ark | フォーカストラップ、エスケープキー、外側クリック、タイプアヘッド、ARIA、ポータル、衝突処理 |
| ドラッグ可能なドロワー | Vaul | ジェスチャーの物理挙動、スナップポイント、スクロールロック |
| トースト | Sonner | スタック表示、スワイプ、タイマー、アクセシビリティ通知 |
| コマンドパレット | cmdk | フィルタリング、キーボード操作、グループ、アクセシビリティ |
| 数値アニメーション | NumberFlow | 数字の列、ロケールに応じた書式設定 |
| データテーブル | TanStack Table (headless) | 並べ替え、仮想化、列の状態 |
| フォーム | ネイティブ機能 + 小規模なバリデーションライブラリ | 制約 API、最初のエラーへのフォーカス |
ルール:ARIA Authoring Practices に仕様がある場合は、それを実装するプリミティブを使います。
状態マトリクス
すべてのインタラクティブコンポーネントは、完成前に以下の状態をすべて網羅します。書き出し、確認した項目にチェックを付けます。
| 状態 | 見た目 | 動作 |
|---|---|---|
| デフォルト | — | — |
| ホバー(ホバー操作が可能なデバイスのみ) | 1段階の変化 | — |
| フォーカス可視 | リング | — |
| アクティブ / 押下 | scale .97 または暗くする | — |
| 選択済み / チェック済み / オン | 塗りつぶしまたはアクセント | aria-* に反映 |
| 無効 | 不透明度 50%、ホバーなし | フォーカス不可、タブ順に残す必要がある場合は aria-disabled |
| 処理中 / 読み込み中 | 同じ幅のままスピナーを表示 | 入力を無視、aria-busy |
| エラー / 無効な値 | 枠線 + メッセージ | aria-invalid、メッセージへの aria-describedby |
| 空(コンテナの場合) | 1行 + 1つのアクション | — |
| 読み取り専用 | テキストスタイル、操作可能であることを示す要素なし | — |
サイズは2つか3つ(sm、md、lg)にします。バリアントは primary、secondary(枠線)、ghost、danger にします。7つにはしません。outline-gradient-xl にもしません。
props ではなく状態からスタイルを決める
コンポーネントと CSS の不一致を防ぐため、スタイルは要素の実際の状態を参照します。
<button data-state={open ? "open" : "closed"} data-size={size} data-variant={variant} aria-busy={busy || undefined} />
.button[data-variant="primary"] { ... }
.button[data-state="open"] { ... }
.button[aria-busy] { ... }
.button:where(:disabled, [aria-disabled="true"]) { ... }
プリミティブはすでに data-state、data-highlighted、data-disabled を出力します。これらを React の状態に複製するのではなく、そのままスタイルに使います。
コンポジションによる API 設計
40個の props を持つ1つのコンポーネントより、組み合わせて使える小さな部品が優れています。
<Dialog.Root>
<Dialog.Trigger asChild><Button>Rename</Button></Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Rename file</Dialog.Title>
<Dialog.Description>…</Dialog.Description>
<Dialog.Close asChild><Button variant="secondary">Cancel</Button></Dialog.Close>
</Dialog.Content>
</Dialog.Root>
ガイドライン:
asChild/renderを使い、ユーザーの要素をラップするのではなく、その要素に動作を適用します。- 残りの props はルート要素にスプレッドします。
classNameとstyleを引き継ぎ、ref も転送します。 - 状態にはブール値の props、バリアントには文字列のユニオン型を使います。ネイティブ属性と
type="..."が衝突しないようにします。 - 制御ありと制御なしのどちらでも動作するようにします。
value+onValueChange、またはdefaultValueを使います。どちらか片方だけにはしません。 - イベントハンドラーには
onXという名前を付け、ネイティブイベントまたは単純な値を受け取るようにします。独自に作った合成オブジェクトは渡しません。 isLoadingとloadingのように重複させません。名前は1つに統一し、どこでも同じものを使います。
値ではなくトークンを使う
コンポーネントでは、色、角丸、余白、イージング、時間にトークンを参照します。コンポーネントにインラインの16進数カラー、0.3s、cubic-bezier が含まれているなら、システムから分岐しています。
角丸は階層ごとに統一します。入力欄とボタンは6〜8px、カードとポップオーバーは8〜12px、ダイアログは12〜16px、ピルは最大値にします。入れ子になった角丸は小さくします。12px のカード内にある要素には、12px ではなく8pxを使います。
配置レイヤー
ポップオーバー、メニュー、ツールチップは body にポータルし、衝突処理を行いながらトリガーを基準に配置し、解決された配置に応じて transform-origin を設定します。表示時には約160msの拡大 / 不透明度アニメーションを使います。ダイアログは中央に配置し、背景を表示して、レイアウトをずらさないスクロールロックを適用します(スクロールバー分を補正します)。フォーカスはダイアログ内に移動し、閉じたときに元の場所へ戻します。
Z-index は単なる数値ではなく、スケールです。--z-sticky: 10; --z-dropdown: 20; --z-dialog: 30; --z-toast: 40。50を超える値があれば、それは不具合です。
入力
- 視認できるラベルを付けます。プレースホルダーはヒントであり、ラベルではありません。
- エラーメッセージは入力欄の下に表示し、
aria-describedbyで関連付けます。キー入力のたびに表示するのではなく、フォーカスが外れたとき、または送信時に表示します。 - 高さは、同じサイズのボタンの高さに合わせます(
smは 32px、mdは 36px、lgは 40px)。 - タッチ操作ではフォントサイズを 16px にします。そうしないと、ブラウザーでズームされます。
inputmodeとautocompleteを設定します。スマートフォンではenterkeyhintも設定します。- 入力欄内のアイコンは、ボタンでない限り装飾要素です。ボタンの描画サイズが 16px でも、タップ領域は 32px にします。
リストと行
- 行全体をクリック対象にします。行を覆う
::after擬似要素をリンクに設定し、名前だけを対象にしないようにします。 - 行ごとに背景を付けず、ポインターに追従する単一のホバー強調表示を使います。
- キーボード操作では、
j/kまたは矢印キーで移動し、Enterで実行できるようにします。フォーカス中の行は枠線で明確に示します。 - 説明文は省略しますが、名前は省略しません。
- 行数が約 200 を超えたら仮想化します。
アイコン
セットは 1 つに統一します。線幅も 1 種類に統一します。用途ごとにサイズを 1 種類にします(高密度 UI では 14px、標準では 16px、タッチ操作では 20px)。テキストの色に追従するよう currentColor を使います。装飾用アイコンには aria-hidden を設定し、アイコンのみのボタンには aria-label を設定します。
テーブル
- ヘッダー行は固定し、下罫線を付けます。
- 数値は右揃えで等幅数字にし、テキストは左揃えにします。中央揃えにはしません。
- 行の高さは 32~40px にします。縞模様は非常に高密度なテーブルでのみ、
--hoverの段階で使います。 - 並べ替えインジケーターはヘッダーに表示し、ホバーしていなくてもアクティブな列が分かるようにします。
- 空の状態はテーブル領域内に表示し、テーブル自体と置き換えないようにします。
コンポーネントのテスト
完了と判断する前に、実際に開いて次の操作を行います。
- マウスを外した状態で、Tab キーを使って操作します。
- 押して、長押しし、押したまま外へドラッグしてから離します。
- 幅を 375px に変更します。
- ダークモードとライトモードを切り替えます。
- 動きを減らす設定を有効にします。
- 現実的な最長のコンテンツと最短のコンテンツを入力します。
- スクリーンリーダーで、名前、ロール、状態が認識されるか確認します。
生成されたコンポーネントの兆候
| 兆候 | 修正 |
|---|---|
useState と外側クリック用フックを使ったカスタムドロップダウン | メニューのプリミティブを使う |
8 種類の値を持つ variant | 4 種類にする |
className が引き継がれていない | 引き継いでマージする |
インラインの transition: all 0.3s | トークンを使う |
| スピナーがラベルに置き換わり、ボタンが縮む | スピナーを重ね、幅を固定する |
| ラベルのないアイコンのみのボタン | aria-label を設定する |
div onClick | button を使う |
| フォーカストラップのないモーダル | ダイアログのプリミティブを使う |
カタログから
- Base UI、Radix Primitives、React Aria、Ark UI — アクセシビリティ対応済みのヘッドレス動作を提供します。
- shadcn/ui — プリミティブの上に完成された外観を加え、リポジトリにコピーして使えます。
- Sonner、Vaul、cmdk、NumberFlow — それぞれ特定の課題を解決します。
- ARIA Authoring Practices — 各パターンのキーボード操作とロールに関する仕様です。
- 詳細は stealth.pm/components をご覧ください。