ブログ

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 のようなインポートできるパーツとしてレンダリングします。

  1. ビヘイビア@ui-sdk/coreフォーカス、キー操作、選択、ポップアップ、ドラッグ&ドロップ。
  2. プリミティブdefinePrimitive状態、API、名前付きスロットを一つの定義に。
  3. パーツ@ui-sdk/react各スロットは data-slot、状態属性、CSS 変数とともにレンダリングされます。
  4. あなたのスタイルclassNameそれらのフックを対象にする Tailwind のユーティリティか素の CSS。

<div data-slot="select-item" data-highlighted data-selected role="option">

className="data-highlighted:bg-accent data-selected:font-medium"

UI SDK でスタイルが付く場所

スタイルは三つのフックを通してパーツに付きます:

  • 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 が挿入されてもあなたのスタイルが勝ちます:

@layer ui-sdk;
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --accent: oklch(0.97 0 0);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
}

@theme inline {
  --color-background: var(--background);
  --color-popover: var(--popover);
  --color-accent: var(--accent);
  --color-muted: var(--muted);
  --color-ring: var(--ring);
}

トークンの完全な一覧は 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 にはラベル、トリガー、値、コンテンツのポップアップ、アイテムがあります。この例は役割を選ぶピッカーで、選ばれたアイテムにチェックマークが付き、リストの開閉時に短いスケールとフェードが入ります:

import {
  Select,
  SelectContent,
  SelectItem,
  SelectItemIndicator,
  SelectLabel,
  SelectTrigger,
  SelectValue,
} from "@ui-sdk/react/select"

const roles = [
  { value: "viewer", label: "Viewer" },
  { value: "editor", label: "Editor" },
  { value: "admin", label: "Admin" },
]

function RoleSelect() {
  return (
    <Select defaultValue="editor" items={roles}>
      <SelectLabel className="text-sm font-medium">Role</SelectLabel>
      <SelectTrigger className="mt-2 flex h-9 w-48 items-center justify-between rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50 data-open:bg-accent data-placeholder:text-muted-foreground">
        <SelectValue />
      </SelectTrigger>
      <SelectContent
        sideOffset={4}
        className="min-w-(--anchor-width) origin-(--transform-origin) rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md transition-[opacity,scale] duration-150 ease-out data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0 motion-reduce:transition-none"
      >
        {roles.map((role) => (
          <SelectItem
            key={role.value}
            value={role.value}
            className="flex h-8 cursor-default items-center justify-between gap-2 rounded-md px-2 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
          >
            {role.label}
            <SelectItemIndicator className="text-xs">✓</SelectItemIndicator>
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  )
}
トリガーの下に開いた Role セレクト。Editor にチェックが付き、Admin がハイライトされている
検証用アプリで開いた Role セレクト。Admin には矢印キーによる data-highlighted が付き、Editor にはチェックマークが表示されている。

各クラスが読むもの:

  • 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 を受け取り、トランジションやアニメーションが終わるまでマウントされたままになります。

  1. 閉じているページにない
  2. 最初のフレームdata-opendata-starting-styleアニメーションの起点となる状態をスタイリングする
  3. 開いているdata-open通常のスタイルへトランジションする
  4. 閉じる途中data-closeddata-ending-style退場が終わるまでマウントされたまま
  5. アンマウントkeepMounted なら非表示で残る
UI SDK のポップアップが開閉時に持つ属性

実際にうまく動かすには、三つの点が重要です。

一つ目に、退場のスタイルは 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 を付けられます:

import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@ui-sdk/react/popover"

function ShareProject() {
  return (
    <Popover>
      <PopoverTrigger className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium outline-none hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50 data-open:bg-accent">
        Share
      </PopoverTrigger>
      <PopoverContent
        side="bottom"
        align="start"
        sideOffset={8}
        className="w-72 origin-(--transform-origin) rounded-lg border border-border bg-popover p-4 text-popover-foreground shadow-md transition-[opacity,scale,translate] duration-200 ease-out data-starting-style:-translate-y-1 data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0 data-ending-style:duration-100 motion-reduce:transition-none"
      >
        <PopoverTitle className="text-sm font-medium">
          Share project
        </PopoverTitle>
        <PopoverDescription className="mt-1 text-sm text-muted-foreground">
          Anyone with the link can view Atlas.
        </PopoverDescription>
        <PopoverClose className="mt-3 h-8 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground">
          Copy link
        </PopoverClose>
      </PopoverContent>
    </Popover>
  )
}
タイトル、説明、Copy link ボタンのある、開いた Share ポップオーバー
入場後の Share ポップオーバー。スケールとフェードインをしながら 4px 下にスライドする。

入場では -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 を使えば、二つのアイコンではなく一つのグラデーションで半分の星を描けます:

import { Rating, RatingItem } from "@ui-sdk/react/rating"

const star =
  "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2l3.1 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.8 21l1.2-6.8-5-4.9 6.9-1z'/%3E%3C/svg%3E\")"

function StayRating() {
  return (
    <Rating
      aria-label="How was your stay?"
      step={0.5}
      defaultValue={3.5}
      className="inline-flex gap-1"
    >
      <RatingItem className="size-7 rounded-sm [&_button]:outline-none has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-ring">
        <span
          aria-hidden="true"
          style={{ maskImage: star, WebkitMaskImage: star }}
          className="absolute inset-0 bg-[linear-gradient(to_right,var(--color-amber-400)_calc(var(--rating-fill)*100%),var(--color-muted)_0)] in-data-highlighted:bg-[linear-gradient(to_right,var(--color-amber-300)_calc(var(--rating-fill)*100%),var(--color-muted)_0)]"
        />
      </RatingItem>
    </Rating>
  )
}
五つの星のうち四つ半が明るいアンバーで塗られ、ポインターがスコアをプレビューしている
ポインターは五つ目の星の前半に置かれている。塗りは --rating-fill に従い、明るいアンバーは data-highlighted によるもの。

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 は内側にある半分幅のラジオボタンのブラウザ標準のリングを消します。これがないと、星の半分を囲む細い枠が描かれます。

四つ目の星にキーボードフォーカスがある二列の星。左ではフォーカスリングが見えない。右では角丸のリングが星を囲んでいる
同じキーボードフォーカスで二つのビルド。左: マスクが RatingItem にあり、アウトラインが切り取られている。右: マスクが内側の span にあり、リングが表示されている。

これは UI SDK に固有の話ではありませんが、アイコン形のマスクは評価を描く一般的な方法なので、出荷する前にキーボードで確認する価値があります。

Color Picker: 描画は CSS 変数が担う

Color Picker は四つの中で最も多くの変数を公開します。すべてのパーツが現在の色を --color-picker-color として書き出します。エリアには二つのチャンネルのグラデーションの組である --color-area-background が、各スライダーには --color-slider-background が付きます。クラスでパーツにサイズを与え、それらの変数を背景として使います。

import {
  ColorPicker,
  ColorPickerArea,
  ColorPickerAreaThumb,
  ColorPickerInput,
  ColorPickerSlider,
  ColorPickerSliderThumb,
  ColorPickerSwatch,
} from "@ui-sdk/react/color-picker"
import { Popover, PopoverContent, PopoverTrigger } from "@ui-sdk/react/popover"

const thumb =
  "absolute size-4 -translate-1/2 rounded-full border-2 border-white bg-(--color-picker-color) shadow outline-none ring-1 ring-black/20 focus-visible:ring-3 focus-visible:ring-ring"

function BrandColor() {
  return (
    <ColorPicker aria-label="Brand color" defaultValue="#6366f1">
      <Popover>
        <PopoverTrigger
          aria-label="Brand color"
          className="flex h-9 items-center gap-2 rounded-md border border-input bg-background pr-3 pl-1.5 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
        >
          <ColorPickerSwatch className="size-6 rounded-sm bg-(--color-picker-color) ring-1 ring-black/10 ring-inset" />
          Brand color
        </PopoverTrigger>
        <PopoverContent
          sideOffset={8}
          className="flex w-60 origin-(--transform-origin) flex-col gap-3 rounded-lg border border-border bg-popover p-3 shadow-md transition-[opacity,scale] duration-150 ease-out data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0 motion-reduce:transition-none"
        >
          <ColorPickerArea className="relative h-36 rounded-md bg-(image:--color-area-background)">
            <ColorPickerAreaThumb className={thumb} />
          </ColorPickerArea>
          <ColorPickerSlider
            channel="hue"
            className="relative h-3 rounded-full bg-(image:--color-slider-background)"
          >
            <ColorPickerSliderThumb className={`${thumb} top-1/2`} />
          </ColorPickerSlider>
          <ColorPickerInput
            aria-label="Hex"
            className="h-8 rounded-md border border-input bg-background px-2 font-mono text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
          />
        </PopoverContent>
      </Popover>
    </ColorPicker>
  )
}
インディゴのスウォッチが付いた Brand color ボタンと、その下に開いたピッカー。彩度エリア、色相スライダー、#6366f1 と表示された 16 進数の入力がある
Popover の中のピッカー。スウォッチ、二つのつまみ、エリアはすべて UI SDK の CSS 変数で描かれている。

これをコピーする前に知っておきたいこと:

  • 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 をキーにしたスタイルシートで、マークアップを短く保ち、コンポーネントの見た目を一か所にまとめられます:

[data-slot="select-item"] {
  display: flex;
  height: 2rem;
  align-items: center;
  border-radius: calc(var(--radius) - 2px);
  padding-inline: 0.5rem;
}

[data-slot="select-item"][data-highlighted] {
  background: var(--accent);
  color: var(--accent-foreground);
}

[data-slot="select-content"][data-starting-style],
[data-slot="select-content"][data-ending-style] {
  opacity: 0;
  scale: 0.95;
}

UI SDK のドキュメントも、自身のデモを素の CSS でスタイリングしています。これが成り立つのは、data-slot がパーツが常に保持する属性の一つだからです。ドキュメントでは、role や tabindex と並んで、props で上書きできない管理対象の属性として挙げられています。各コンポーネントファイルがパーツをラップして独自のクラスを付ける shadcn/ui 風のプロジェクトでは、パーツにユーティリティを付けるほうがたいてい簡単です。素の CSS のテーマは丸ごと差し替えやすくなります。

Radix や Base UI から shadcn 風のクラスを移す

コンポーネントが shadcn/ui 由来なら、クラスのほとんどはそのまま使えます。違いはいくつかの属性名とポップアップの構造にあります。

関心事RadixBase UIUI 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, ContentPortal, Positioner, PopupContent のみ

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 版のコンポーネントを作っていて、この記事は最初のいくつかを作りながら学んだことをまとめたものです。