Tailwind で Vercel の UI SDK をスタイリングする: data-slot、状態属性、入場と退場のアニメーション
UI SDK を Tailwind v4 で装う。data-slot、状態属性、CSS 変数、アニメーション。
Preet Suthar · · 9分で読めます
- React
- UI SDK
- Tailwind CSS
- shadcn/ui
Vercel Labs は 2026 年 11 月 12 日に UI SDK を npm で公開しました。React、Vue、Svelte、Solid、Web Components 向けの、スタイルを持たないアクセシブルなコンポーネント群で、ビヘイビアのコアライブラリの上に作られています。コンポーネントはフォーカス、キーボード、ARIA を扱います。見た目はあなたの仕事です。
この記事では、そのうち四つを Tailwind CSS v4 と shadcn 風のテーマトークンでスタイリングします。Select、Popover、Rating、Color Picker です。それぞれ異なるスタイリングのフックを示します。以下のスニペットはすべて、Vite 8、React 19.3、Tailwind CSS 4.3 と、0.1.0-beta.1 の @ui-sdk/react と @ui-sdk/core で作った検証用アプリで動いています。UI SDK はベータ版で、ドキュメントにも API がまだ変わる可能性があると書かれているので、インストールしたバージョンを確認してください。
UI SDK のスタイリングの仕組み
UI SDK には三つの層があります。@ui-sdk/core は、ロービングフォーカス、タイプアヘッド、ポップアップなどのビヘイビアを持ちます。definePrimitive で書かれたプリミティブは、ビヘイビアを状態と名前付きスロットと組み合わせます。@ui-sdk/react のようなフレームワークアダプターは、各スロットを SelectTrigger や RatingItem のようなインポートできるパーツとしてレンダリングします。
- ビヘイビア
@ui-sdk/coreフォーカス、キー操作、選択、ポップアップ、ドラッグ&ドロップ。 - プリミティブ
definePrimitive状態、API、名前付きスロットを一つの定義に。 - パーツ
@ui-sdk/react各スロットはdata-slot、状態属性、CSS 変数とともにレンダリングされます。 - あなたのスタイル
classNameそれらのフックを対象にする Tailwind のユーティリティか素の CSS。
<div data-slot="select-item" data-highlighted data-selected role="option">
className="data-highlighted:bg-accent data-selected:font-medium"
スタイルは三つのフックを通してパーツに付きます:
data-slot: すべてのパーツが持っていて、たとえばdata-slot="select-item"のようになります。グローバル CSS とエージェントのためにパーツに名前を付けるものです。- 状態属性:
data-open、data-highlighted、data-selected、data-disabledなど。ほとんどは有無で表されます。data-side="top"やdata-orientation="vertical"のように値を持つものもあります。 - CSS 変数: クラスでは表せない値。たとえばポップアップの
--anchor-widthや星の--rating-fillです。
className と style は、Base UI と同じようにパーツの状態の関数にもできます。Tailwind ではほとんど必要ありません。v4 には任意のデータ属性に対するバリアントがあるからです。data-highlighted:bg-accent は data-highlighted がある間だけ適用されます。
ドキュメントは、変わる可能性のある生成された ID ではなく、これらの属性をスタイリングするよう求めています。各コンポーネントのページには Styling セクションと Data Attributes セクションがあり、各パーツが公開するものが正確に並んでいます。その一覧が契約です。
Tailwind v4 と ui-sdk レイヤーを設定する
UI SDK には、閉じたポップアップを隠しておくためなど、独自の CSS が少し必要です。その CSS は実行時に ui-sdk という名前のカスケードレイヤーで追加されます。グローバルスタイルシートで Tailwind の import より上にこのレイヤーを最初に宣言しておけば、ページのどこにランタイム CSS が挿入されてもあなたのスタイルが勝ちます:
トークンの完全な一覧は shadcn/ui が生成するもので、同じ名前を上書きする .dark ブロックが付きます。ここでは省略しました。UI SDK 自体はこれらのトークンを読みません。意味があるのは下のクラスがこれらを使うからで、つまり UI SDK のコンポーネントと shadcn/ui のコンポーネントで一つのテーマを共有できます。
マウントするプロバイダーも、パッケージからインポートするスタイルシートもありません。スタイルに 'unsafe-inline' を許可しない Content Security Policy を使っている場合は、Styling and Animation ガイドの CSP セクションを読んでください。ランタイム CSS には nonce か constructed style sheets が必要です。
Select: 状態属性とアニメーションするリスト
Select にはラベル、トリガー、値、コンテンツのポップアップ、アイテムがあります。この例は役割を選ぶピッカーで、選ばれたアイテムにチェックマークが付き、リストの開閉時に短いスケールとフェードが入ります:

各クラスが読むもの:
data-open:bg-accentは、リストが開いている間トリガーに色を付けたままにします。data-placeholder:は何も選択されていない間のトリガーをスタイリングします。ここではデフォルト値があるので適用されることはありませんが、残しておいても損はありません。min-w-(--anchor-width)はリストを少なくともトリガーと同じ幅にします。Tailwind v4 では CSS 変数の値をvar()なしで(--name)と書きます。origin-(--transform-origin)は、リストが開く側からスケールさせます。この変数は UI SDK がコンテンツに設定します。data-highlighted:はポインターの下やキーボードフォーカスのあるアイテムを示します。選択されたアイテムにはdata-selectedが付き、SelectItemIndicatorは選択されたアイテムの中でだけレンダリングされます。
items を渡すのは任意ですが、ドキュメントは推奨しています。リストがレンダリングされる前にトリガーが選択中のラベルを表示でき、エージェントブリッジはリストが閉じている間も選択肢を読めます。
キーボードによるハイライトとポインターによるハイライトを区別したい場合、コンテンツには data-modality="keyboard" または "pointer" が付きます。キーボードユーザーにだけ表示するフォーカスリングは、アイテムに in-data-[modality=keyboard]:data-highlighted:ring-2 と書きます。
ポップアップのライフサイクル
上のアニメーションは、トランジション中にだけ存在する二つの属性を使っています。コンテンツは最初のフレームで data-starting-style を受け取るので、トランジションには開始する状態があります。閉じるときは data-ending-style を受け取り、トランジションやアニメーションが終わるまでマウントされたままになります。
- 閉じているページにない
- 最初のフレーム
data-opendata-starting-styleアニメーションの起点となる状態をスタイリングする - 開いている
data-open通常のスタイルへトランジションする - 閉じる途中
data-closeddata-ending-style退場が終わるまでマウントされたまま - アンマウントkeepMounted なら非表示で残る
実際にうまく動かすには、三つの点が重要です。
一つ目に、退場のスタイルは data-closed ではなく data-ending-style に付けます。data-closed は状態を表しますが、退場のために要素を残しておくものではないとドキュメントにはっきり書かれています。
二つ目に、UI SDK のポップアップはブラウザのトップレイヤーを使います。コンテンツはその場所に、コンポーネントの子としてレンダリングされ、ブラウザがそれをページの上に持ち上げます。私の検証用アプリでは、開いたコンテンツに popover="manual" が付き、:popover-open にマッチしました。Portal や Positioner のパーツはなく、sticky ヘッダーより上に来るかどうかを z-index が決めることもありません。
三つ目に、Motion のようなアニメーションライブラリが閉じたコンテンツを DOM に残しておく必要がある場合は、keepMounted を設定します。onOpenChangeComplete は、アニメーションを含めて開閉が終わった後に実行されます。
Popover: 入場より速い退場
入場と退場のタイミングを揃える必要はありません。200ms でスライドインして 100ms でフェードアウトするポップオーバーは、どちらも 200ms かかるものより速く感じます。退場には専用の属性があるので、Tailwind で退場だけに別の duration を付けられます:

入場では -translate-y-1、scale-95、opacity-0 から通常の状態へ移ります。退場は data-ending-style:duration-100 でスケールとフェードだけを行います。motion-reduce:transition-none は動きを減らしたい人に対して両方をオフにし、それでもポップオーバーは開閉します。
PopoverTitle と PopoverDescription は見た目以上に重要です。コンテンツはダイアログで、これらのパーツがアクセシブルな名前と説明を与えます。タイトルがない場合は、コンテンツに aria-label を付けてください。
Rating: 星ではなく塗りをスタイリングする
Rating は星として描かれたラジオグループです。RatingItem を一つレンダリングすると、Rating がそれを星ごとに繰り返します。各アイテムは、表示中のスコアが自分の星をどれだけ覆っているかを 0 から 1 の値で --rating-fill に出します。data-filled は塗りつぶされた星、data-partial は半分のスコアが終わる星、data-highlighted はポインターがプレビューしているスコアまでの星を示します。
--rating-fill を使えば、二つのアイコンではなく一つのグラデーションで半分の星を描けます:

step={0.5} を指定すると、半分の星がそれぞれ独立したラジオになり、「3.5 stars」のような名前が付きます。ラジオは Rating がインラインスタイルでアイコンの上に配置するボタンで、アイテムにも同じ方法で position: relative が付きます。だから span は追加のクラスなしで absolute inset-0 を使えます。
キーボード操作は何もしなくても動きます。私のテストでは、チェックされた星にフォーカスして右矢印キーを押すとスコアが 3.5 から 4 に変わり、塗りも追従しました。
フォーカス可能なアイテムにはマスクをかけない
最初のバージョンでは、星のマスクを RatingItem 自体にかけていました。Tab でフォーカスを移すまでは正しく見えましたが、CSS マスクは要素が描画するものすべてをアウトラインも含めて切り取るため、フォーカスリングが消えてしまいました。マスクを内側の span に移すと直りました。アイテムは has-focus-visible: で角丸のアウトラインを保ち、[&_button]:outline-none は内側にある半分幅のラジオボタンのブラウザ標準のリングを消します。これがないと、星の半分を囲む細い枠が描かれます。

これは UI SDK に固有の話ではありませんが、アイコン形のマスクは評価を描く一般的な方法なので、出荷する前にキーボードで確認する価値があります。
Color Picker: 描画は CSS 変数が担う
Color Picker は四つの中で最も多くの変数を公開します。すべてのパーツが現在の色を --color-picker-color として書き出します。エリアには二つのチャンネルのグラデーションの組である --color-area-background が、各スライダーには --color-slider-background が付きます。クラスでパーツにサイズを与え、それらの変数を背景として使います。

これをコピーする前に知っておきたいこと:
- Popover は ColorPicker の中に置きます。こうするとポップアップ内のパーツとトリガー内のスウォッチが同じ色を編集し、表示します。
bg-(image:--color-area-background)は Tailwind v4 の型ヒントです。image:がないと Tailwind は変数を色として読み、グラデーションが描画されません。- つまみには
position: absoluteと、パーセントでの位置がインラインスタイルで付きます。クラスで必要なのは各つまみをその点の中央に置くことだけで、それが-translate-1/2の役割です。スライダーのつまみにはtop-1/2も必要です。インラインスタイルはトラックに沿った位置しか設定しないからです。 - スウォッチはスクリーンリーダーにとっては、色の名前が付いた画像です。私のビルドでは
#6366f1が「vivid purple」と読み上げられました。見えているテキストだけでは色の用途がわからないこともあるので、トリガーには自分で名前を付けてください。 - OKLCH の場合は、エリアの中に
ColorPickerAreaCanvasを追加します。グラデーションでは OKLCH の色域の境界を描けないので、キャンバスがピクセル単位で描画します。
素の CSS で data-slot を使ってスタイリングする
すべてのパーツにユーティリティクラスを付けるのが一つの方法です。もう一つは data-slot をキーにしたスタイルシートで、マークアップを短く保ち、コンポーネントの見た目を一か所にまとめられます:
UI SDK のドキュメントも、自身のデモを素の CSS でスタイリングしています。これが成り立つのは、data-slot がパーツが常に保持する属性の一つだからです。ドキュメントでは、role や tabindex と並んで、props で上書きできない管理対象の属性として挙げられています。各コンポーネントファイルがパーツをラップして独自のクラスを付ける shadcn/ui 風のプロジェクトでは、パーツにユーティリティを付けるほうがたいてい簡単です。素の CSS のテーマは丸ごと差し替えやすくなります。
Radix や Base UI から shadcn 風のクラスを移す
コンポーネントが shadcn/ui 由来なら、クラスのほとんどはそのまま使えます。違いはいくつかの属性名とポップアップの構造にあります。
| 関心事 | Radix | Base UI | UI SDK |
|---|---|---|---|
| 開いた状態 | data-[state=open]: | data-open: | data-open: |
| 入場のスタイル | data-[state=open] のキーフレーム | data-starting-style: | data-starting-style: |
| 退場のスタイル | data-[state=closed] のキーフレーム | data-ending-style: | data-ending-style: |
| 変形の原点 | --radix-popover-content-transform-origin | --transform-origin | --transform-origin |
| トリガーの幅 | --radix-popover-trigger-width | --anchor-width | --anchor-width |
| ポップアップのパーツ | Portal, Content | Portal, Positioner, Popup | Content のみ |
Base UI から来る場合、ポップアップの属性名は同じなので、クラス文字列はほぼそのまま貼り付けられます。Portal と Positioner のラッパーを削除し、side、align、sideOffset などの配置 props をコンテンツに移します。Radix から来る場合は、data-[state=…] のバリアントとキーフレームアニメーションを starting と ending の属性に置き換えます。どちらの場合も、各コンポーネントの Data Attributes の一覧を確認してください。共通に見える名前でも違うことがあるからです。たとえば Base UI の Select には独自の属性セットがあり、UI SDK の Select にもあります。
スタイリング以外も含めた三つのライブラリ全体の比較は UI SDK vs Base UI vs Radix にあります。
私がつまずいたこと
同じ失敗を繰り返さないための短いリスト:
- 退場のために
data-closedをスタイリングする。 何もアニメーションしません。data-ending-styleを使ってください。 @layer ui-sdk;を忘れる。 これを最初に書かないと、ランタイム CSS がカスケード上であなたのスタイルより後に来ることがあります。- フォーカス可能なパーツへのマスク。 フォーカスのアウトラインが切り取られます。内側の要素にマスクをかけてください。
- 子要素から
--available-heightを読む。 ドキュメントにあるとおり、この変数は継承されません。ポップアップ上でたとえば[--list-height:var(--available-height)]のようにコピーし、その下でmax-h-(--list-height)を読みます。 - 変数からのグラデーション。
bg-(--name)ではなくbg-(image:--name)を使います。
UI SDK はベータ版で、執筆時点で公開から一日しか経っていません。ここに書いたことはすべて 0.1.0-beta.1 でドキュメントどおりに動き、node_modules/@ui-sdk/react/docs/ に同梱されたドキュメントの属性一覧は、コンポーネントが実際にレンダリングしたものと一致しました。HextaUI は Base UI 版とは別に、スタイル付きの UI SDK 版のコンポーネントを作っていて、この記事は最初のいくつかを作りながら学んだことをまとめたものです。




