Radix Primitives や
React Aria のようなヘッドレス UI ライブラリは、スタイルを持たない代わりに、キーボード操作、フォーカス管理、ARIA 属性といった振る舞いを提供してきました。開発者はメニューやダイアログの複雑なアクセシビリティを自分で実装せずに済み、見た目だけを自分のデザインシステムに合わせられます。
ただし、この恩恵を受けられるのは、ライブラリが提供しているコンポーネントを使う場合に限られます。例えばカンバンボードや特定の業務に合わせたピッカーは、一般的なヘッドレス UI ライブラリには含まれていません。コーディングエージェントが台頭したことにより、ライブラリにないコンポーネントをエージェントが直接作るようになりました。エージェントはこうしたコンポーネントを短時間で生成しますが、完成したコンポーネントに組み込まれた振る舞いをそのまま流用できないため、フォーカスの移動先や矢印キーの扱い、スクリーンリーダーへの読み上げを毎回一から書くことになり、細かな誤りが混ざりがちです。
また、完成した UI を人間ではなくエージェントが操作する場面が増えました。エージェントは DOM のアクセシビリティツリーを読み、コンポーネントの意味を理解しますが、DOM ツリーからはこのコンポーネントで何ができるかはわかりません。そのため、エージェントは人間と同じクリックやキー入力を再現して UI を操作することになります。このことは、人間向けの UI ではエージェントが扱いにくいことを意味します。
例えば、独自に実装したセレクトボックスで都道府県を選ぶ場面を考えてみましょう。エージェントはセレクトボックスをクリックし、選択肢の一覧が開くのを待ち、アクセシビリティツリーを読み直して選択肢を見つけてから、目的の選択肢をクリックします。操作のたびにツールを呼び出してページを読み直すため、値を 1 つ選ぶだけで何往復もかかり、その分の時間とトークンを消費します。
一方、セレクトボックス自身が「選べる選択肢の一覧」と「値を設定する操作」をエージェントに公開していれば、エージェントは選択肢を指定して 1 回の呼び出しで値を設定できます。人間向けの入力を再現させるのではなく、コンポーネントの意味の単位で直接操作できるようにすることが、エージェントにとって扱いやすい UI の条件になります。
2026 年 11 月 12 日に Vercel Labs がベータ版として公開した UI SDK は、この 2 つの変化を前提に設計されたヘッドレス UI ライブラリです。
この記事では、UI SDK の構成と既存のヘッドレス UI との違いを整理したうえで、Claude Code に UI SDK でトランスファーリストを作らせ、そのコンポーネントをエージェントが操作するまでを確認します。
UI SDK は 2026 年 11 月 13 日時点でベータ版(@ui-sdk/[email protected])です。API、ドキュメント、挙動は正式リリースまでに変わる可能性があります。
UI SDK の特徴
UI SDK は自身を「コンポーネントライブラリではなく、その下の層」と位置付けています。リポジトリの AGENTS.md は次のように説明しています。
It isn't a component library. It's the layer underneath one: …
この下の層に相当するのが behavior です。behavior は「矢印キーでフォーカスを移動する」「文字を入力すると該当する選択肢へ移動する」「ボタンの隣にポップアップを開き、閉じたらフォーカスを戻す」のような、UI が行う 1 つの仕事を担う関数で、それぞれ createRovingFocus、createTypeahead、createPopup という名前で @ui-sdk/core から提供されます。50 を超える behavior があり、組み合わせることで複雑なコンポーネントを作れます。
UI SDK は同じ behavior を使って作った Select や Date Picker など 50 以上のコンポーネントも提供していますが、ライブラリにないコンポーネントを behavior から組み立てることを前提にしている点が特徴です。UI SDK が既存のライブラリと異なるのは、AI エージェントを次の 2 つの立場で設計に組み込んでいる点です。
- コンポーネントの作り手: ライブラリにないコンポーネントを、エージェントが behavior から組み立てる
- UI の使い手: 完成した画面のコンポーネントを、エージェントが状態とアクションの単位で読み書きする
behavior と primitive
UI SDK でコンポーネントを作るときは、definePrimitive で primitive を定義します。primitive は 1 つのコンポーネントの状態(state)、操作(api)、DOM 要素の単位であるパーツ(slots)をまとめた定義です。behavior は api の中で生成し、パーツの属性やイベントへ接続します。
primitive はフレームワークに依存しません。各フレームワークのアダプターが提供する createParts に渡すと、React なら Root や Input のようなコンポーネントが得られます。パーツはスタイルを持たず、状態を data-* 属性と CSS 変数として公開します。
UI SDK の README は、primitive を人間が手で書くことよりも、エージェントが書くことを前提にしていると説明しています。
Don't worry, you don't need to learn how to write this. Your agents will.
エージェントにトランスファーリストを作らせる
UI SDK を使用してトランスファーリストを作ってみましょう。トランスファーリストは左右 2 つのリストの間で項目を移動するコンポーネントで、矢印キーでの移動、複数選択、移動後のフォーカスの行き先、スクリーンリーダーへの読み上げといった振る舞いを適切に実装する必要があります。
準備
Vite の React テンプレートで作ったプロジェクトに、UI SDK のコアと React アダプターをインストールします。React アダプターは React 19 以降が必要です。
npm install @ui-sdk/[email protected] @ui-sdk/[email protected]UI SDK は公式のスキル ui-sdk を配布しています。skills CLI でプロジェクトに追加します。
npx skills add vercel-labs/ui --skill ui-sdk同じスキルは node_modules/@ui-sdk/core/skills/ui-sdk/SKILL.md にも同梱されています。
プロンプト
公式ドキュメントは、作りたいコンポーネントで人がどのような操作をできるべきかを、具体的に伝えるよう勧めています。それに従って、次のプロンプトを書きました。
ui-sdk スキルを使って、React でトランスファーリストを作ってください。
左に「未割り当て」、右に「割り当て済み」の 2 つのリストがあり、プロジェクトのメンバーを左右に移動できるコンポーネントです。
- 各リストには Tab で 1 回だけフォーカスが入り、上下の矢印キーで項目間を移動する
- Space で項目を選択・選択解除でき、複数の項目を選択できる
- 2 つのリストの間にある「割り当てる」「外す」ボタンで、選択中の項目を反対側のリストへ移動する
- 移動後もフォーカスを見失わないようにする。移動元のリストに項目が残っていれば、そのリストにフォーカスを戻す
- 移動した件数をスクリーンリーダーに読み上げる
- 割り当て済みの値は親コンポーネントが持ち、変更を受け取れるようにする
- エージェントが createAgent を通じてメンバーを割り当てたり外したりできるようにする
固定の 6 人のメンバーと、1 つの例のページから始めてください。スタイルは最小限で構いません。
キーボード操作、フォーカス、ARIA をテストし、typecheck と関連するテストを実行してください。開発サーバーは起動しないでください。最後に、実行したコマンドと結果、確認できなかったことを報告してください。このプロンプトで Claude Code に作業してもらいます。実行ログを見ると、エージェントはスキルの指示どおり、llms.txt、core.md の該当するセクション、型定義を読んでから実装に入っていました。作成されたファイルは次のとおりです。
src/transfer-list/primitive.ts: トランスファーリストの primitivesrc/transfer-list/transfer-list.tsx: React 用のラッパーsrc/transfer-list/transfer-list.test.tsx: Vitest のテストsrc/agent.ts:createAgent()でブリッジを作るモジュールsrc/App.tsx、src/members.ts: 動作例のページ
生成された primitive を読む
src/transfer-list/primitive.ts の中心は、definePrimitive の呼び出し 1 つです。まず全体の構成を見てみましょう。
import {
announce,
createChange,
createFocusRecovery,
createRovingFocus,
createSelection,
definePrimitive,
type ChangeDetails,
} from "@ui-sdk/core";
// 型(TransferListOptions、TransferListState など)と補助関数は省略
const transferList = definePrimitive({
// コンポーネントの名前。スナップショットの component にもこの名前が入る
name: "transfer-list",
// コンポーネントが受け取るオプションと、その種類
options: {
items: "value",
value: "list",
defaultValue: "list",
onValueChange: "callback",
},
// 親が value、defaultValue、onValueChange で制御できる状態
controlled: ["value"],
// 状態の初期値
state: (options: TransferListOptions): TransferListState => ({
items: options.items ?? [],
value: options.value ?? options.defaultValue ?? [],
selected: [],
focusedAvailable: null,
focusedAssigned: null,
}),
// オプションが変わるたびに、制御されている値を状態へ反映する(省略)
sync: (options) => ({ /* ... */ }),
// コンポーネントの操作。behavior はここで生成する
api: (context) => { /* ... */ },
// DOM 要素の単位であるパーツ
slots: (slot) => { /* ... */ },
// エージェントに公開する操作
agent: { actions: ["assign", "unassign"], items: (api) => api.items() },
});options、state、api、slots が、前の節で説明した primitive の構成要素にあたります。ここからは、api、slots、agent の中身を順に見ていきます。
api: behavior を組み合わせる
api では、次の behavior と関数を組み合わせていました。いずれも @ui-sdk/core から提供される関数です。
createSelection: 複数選択の状態を管理するcreateRovingFocus: リストごとに Tab で止まる位置を 1 つにし、矢印キーで項目間のフォーカスを移動するcreateFocusRecovery: フォーカスしていた項目が消えたとき、代わりの要素へフォーカスを移すannounce: スクリーンリーダーに読み上げる文をライブリージョンに入れるcreateChange: 値の変更をonValueChangeに通知し、親がキャンセルできるようにする
api の全体は次のとおりです。
api: (context) => {
const { store } = context;
const change = createChange(context);
const selection = createSelection(context, {
key: "selected",
multiple: () => true,
// set、order は省略
});
// 左右のリストごとに、フォーカスの移動と回復の behavior を作る
const block = (side: Side) => ({
roving: createRovingFocus<TransferListState, OptionProps>(context, {
slot: optionSlot[side], // "availableOption" か "assignedOption"
key: focusKey[side], // "focusedAvailable" か "focusedAssigned"
}),
recovery: createFocusRecovery<OptionProps>({
fallback: (lost) => {
// 消えた項目に最も近い、同じリストの項目を返す(省略)
},
}),
});
const blocks = {
available: block("available"),
assigned: block("assigned"),
};
const move = (to: Side, requested: string[] | undefined, event?: Event) => {
// 後述
};
return {
selection,
blocks,
assign: (ids?: string[], event?: Event) => move("assigned", ids, event),
unassign: (ids?: string[], event?: Event) => move("available", ids, event),
items: () =>
store.get().items.map((item) => ({ value: item.id, label: item.label })),
};
},左右のリストはどちらも同じ振る舞いを持つため、block という関数で同じ behavior を 2 つ作っています。createRovingFocus の slot には、フォーカスを移動させるパーツの名前を渡しています。key は、どの項目にフォーカスがあるかを保存する状態のキーです。
api が返すオブジェクトが、コンポーネントの操作になります。blocks と selection は後述の slots から、assign、unassign、items は agent キーから参照されます。
assign と unassign が呼ぶ move 関数の後半では、change 関数を呼び、親が変更をキャンセルしなかった場合にだけ、件数を読み上げてフォーカスを移動します。
const move = (to: Side, requested: string[] | undefined, event?: Event) => {
// 移動元のリスト(from)、移動する項目の id(ids)、
// 移動後の割り当て済みの値(value)を求める(省略)
if (
!change("value", value, {
reason: to === "assigned" ? "assign" : "unassign",
event,
})
) {
return false;
}
store.set({ selected: state.selected.filter((id) => !moved.has(id)) });
announce(
to === "assigned"
? `${ids.length} 人を割り当てました`
: `${ids.length} 人を外しました`,
);
// 移動元のリストに残った項目のうち、移動した項目に最も近いもの
const stay = nearest(
state.items.map((item) => item.id),
(id) => pool.includes(id) && !moved.has(id),
ids[0],
);
const target = stay
? { side: from, key: stay }
: { side: to, key: ids[0] };
blocks[target.side].roving.focus(target.key);
return true;
};change の第 3 引数の reason は、変更の理由を表す文字列です。親は onValueChange(value, details) の details.reason で理由を、details.source で変更の出どころ(キーボード、マウス、エージェントなど)を受け取ります。移動元のリストに項目が残っていればそのうち最も近い項目へ、残っていなければ移動先のリストへフォーカスを移します。
slots: パーツを定義する
slots は、slot 関数でパーツを定義して返します。各項目のパーツでは、attrs が状態から ARIA 属性を計算し、bind が要素と behavior を接続します。
slots: (slot) => {
const option = (side: Side) =>
slot<OptionProps>({
role: "option",
focus: "roving",
props: { value: "string" },
attrs: (state, part) => ({
tabindex: part.api.blocks[side].roving.tabIndex(state, part),
"aria-selected": String(state.selected.includes(part.key)),
"data-selected": state.selected.includes(part.key) ? "" : undefined,
}),
// press、on.keydown、bind は省略
});
// リスト(listbox)と移動ボタン(button)のパーツも同じように定義する(省略)
return {
root: slot({ role: "group" }),
available: list(),
availableOption: option("available"),
assigned: list(),
assignedOption: option("assigned"),
assign: mover("assigned"),
unassign: mover("available"),
};
},attrs の part.api から、api が返した blocks を参照しています。返したオブジェクトのキー(root、availableOption など)が、そのままパーツの名前になります。
agent: エージェントに公開する操作
definePrimitive の最後の agent キーは、エージェントに公開する API を宣言する部分です。actions にはエージェントが呼び出せる操作の名前を、items にはエージェントが取得できる項目の一覧を返す関数を指定します。
agent: { actions: ["assign", "unassign"], items: (api) => api.items() },actions の assign と unassign は、api が返した同名のメソッドです。items の引数の api も、api が返したオブジェクトです。エージェントがどのように操作するかは、後の節で説明します。
React のラッパー
React のラッパーでは、createParts に primitive と各パーツの要素名を渡しています。
import { createParts } from "@ui-sdk/react/primitive";
import { transferList } from "./primitive";
const parts = createParts(transferList, {
root: "div",
available: "ul",
availableOption: "li",
assigned: "ul",
assignedOption: "li",
assign: "button",
unassign: "button",
});parts.Root、parts.Available、parts.AvailableOption などのコンポーネントが得られます。同じファイルの TransferList コンポーネントは、これらのパーツに見出しとメンバーの一覧を配置します。
import { useId } from "react";
import {
listOf,
type TransferItem,
type TransferListOptions,
} from "./primitive";
type TransferListProps = {
label: string;
items: TransferItem[];
value: string[];
onValueChange: NonNullable<TransferListOptions["onValueChange"]>;
};
function TransferList({
label,
items,
value,
onValueChange,
}: TransferListProps) {
const id = useId();
const state = {
items,
value,
selected: [],
focusedAvailable: null,
focusedAssigned: null,
};
// listOf は primitive.ts の関数で、value に含まれるかどうかで項目を左右に振り分ける
const available = listOf(state, "available");
const assigned = listOf(state, "assigned");
return (
<parts.Root
aria-label={label}
items={items}
value={value}
onValueChange={onValueChange}
>
<div>
<h3 id={`${id}-available`}>未割り当て</h3>
<parts.Available aria-labelledby={`${id}-available`}>
{available.map((item) => (
<parts.AvailableOption key={item.id} value={item.id}>
{item.label}
</parts.AvailableOption>
))}
</parts.Available>
{available.length === 0 && <p>メンバーはいません</p>}
</div>
<div>
<parts.Assign>割り当てる</parts.Assign>
<parts.Unassign>外す</parts.Unassign>
</div>
<div>
<h3 id={`${id}-assigned`}>割り当て済み</h3>
<parts.Assigned aria-labelledby={`${id}-assigned`}>
{assigned.map((item) => (
<parts.AssignedOption key={item.id} value={item.id}>
{item.label}
</parts.AssignedOption>
))}
</parts.Assigned>
{assigned.length === 0 && <p>メンバーはいません</p>}
</div>
</parts.Root>
);
}
export { TransferList, type TransferListProps };items、value、onValueChange は parts.Root に渡すと primitive のオプションになり、aria-label などの HTML 属性はそのまま要素に適用されます。各項目のパーツには key と value に同じ id を渡しており、primitive はこの value を part.key として、選択状態や ARIA 属性の計算に使います。キーボード操作、選択、ARIA 属性はパーツ側が持つため、ラッパーはマークアップを並べるだけで済んでいます。
最終的に App.tsx では TransferList コンポーネントを次のように使っています。
import { useState } from "react";
import { members } from "./members";
import { TransferList } from "./transfer-list/transfer-list";
function App() {
const [assigned, setAssigned] = useState<string[]>(["aiko"]);
return (
<main>
<h1>プロジェクトメンバー</h1>
<TransferList
label="プロジェクトメンバーの割り当て"
items={members}
value={assigned}
onValueChange={setAssigned}
/>
<p>
割り当て済み:{" "}
{members
.filter((member) => assigned.includes(member.id))
.map((member) => member.label)
.join("、") || "なし"}
</p>
</main>
);
}
export default App;ブラウザで操作する
開発サーバーを起動すると、次のトランスファーリストが表示されます。左右の「未割り当て」と「割り当て済み」リストにメンバーの一覧が並んでおり、2 つのリストの間に「割り当てる」と「外す」のボタンがあります。

このトランスファーリストでは、キーボードで次の操作ができます。
- Tab で各リストにフォーカスが入る。リスト内の項目間は上下の矢印キーで移動する
- Space で項目を選択・選択解除する。複数の項目を選択でき、選択した項目は
aria-selected="true"になる - 「割り当てる」「外す」ボタンで、選択中の項目を反対側のリストへ移動する
- 移動後、移動元のリストに項目が残っていれば、その中で最も近い項目にフォーカスが移る。残っていなければ、移動先のリストにフォーカスが移る
- 移動した件数が、
aria-live="polite"のライブリージョンを通じてスクリーンリーダーに読み上げられる
実際に、以下のとおりにリストを移動できていることが確認できました。

エージェントが UI を操作する
ここからは、完成した画面をエージェントに操作させてみます。UI SDK はエージェントが操作しやすい UI を提供するために、エージェントブリッジという仕組みを用意しています。エージェントブリッジは、ページ上にある UI SDK のコンポーネントと AI エージェントのあいだを仲介するオブジェクトで、createAgent で作成します。ブリッジは次の 3 つのメソッドを持ち、それぞれをそのままエージェントのツールとして使えます。
snapshot: ページ上のコンポーネントと、その状態、呼び出せる操作を一覧するquery: ラベルやテキストから、コンポーネントや Select の選択肢などを探すact: 手順を受け取り、各コンポーネントの API を呼び出して操作する
ブリッジはコンポーネントが公開している API を直接呼び出します。これにより、エージェントはスクリーンショットを読んだり、クリックやキー入力を再現せずに UI を操作できるようになります。
作成したブリッジは、agentTools が提供するツール定義と組み合わせて自前のエージェントに渡したり、registerTools で WebMCP のツールとしてブラウザ内蔵のエージェントに公開したりできます。この記事では、ブラウザのコンソールからブリッジを呼び出して動作を確認します。
スナップショットを読む
ブリッジは生成されたコンポーネントを内部のレジストリから見つけるため、ページを描画する前に作成する必要があります。src/agent.ts でブリッジを作成し、コンソールから試せるよう Object.assign() で window にも公開します。
import { createAgent } from "@ui-sdk/react/agent";
const agent = createAgent();
Object.assign(window, { agent });
export { agent };main.tsx では、アプリを描画する前にこのモジュールを読み込みます。import "./agent" をファイルの先頭に置き、ほかのどのモジュールよりも先にブリッジが作られるようにします。
import "./agent";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);ブラウザのコンソールで window.agent.snapshot() を呼ぶと、次の結果が返りました。
[
{
"ref": "c1",
"component": "transfer-list",
"actions": ["assign", "unassign"],
"label": "プロジェクトメンバーの割り当て",
"value": ["aiko", "chika", "emi"],
"items": [
{ "value": "aiko", "label": "相沢 愛子" },
{ "value": "bunta", "label": "馬場 文太" },
// 省略...
]
}
]actions には、primitive の agent キーで宣言した assign と unassign が並んでいます。label はコンポーネントのアクセシブルな名前で、ラッパーが Root に渡した aria-label から計算されています。items は agent キーの items 関数が返した値です。
アクションを実行する
agent.act() に手順(steps)を渡すと、ブリッジがコンポーネントの API を呼び出します。ref にはスナップショットの ref かラベルを指定でき、1 回の呼び出しで複数の手順を実行できます。
await window.agent.act({
steps: [
{ ref: "プロジェクトメンバーの割り当て", action: "assign", args: [["古川 文夫"]] },
{ ref: "プロジェクトメンバーの割り当て", action: "unassign", args: [["emi"]] },
],
});実行すると、次の結果が返りました。
{
"ok": true,
"steps": [
{
"step": 0,
"ref": "c1",
"changes": [
{
"component": "transfer-list",
"label": "プロジェクトメンバーの割り当て",
"key": "value",
"value": ["aiko", "chika", "emi", "fumio"]
}
]
},
// 2 つ目の手順(unassign)の結果は省略
],
"appeared": [],
"values": {
"c1 プロジェクトメンバーの割り当て": ["aiko", "chika", "fumio"]
}
}steps には手順ごとに変化した値が、values にはすべての手順を実行した後の値が入っています。エージェントは画面を読み直さなくても、この結果から操作が反映されたことを確認できます。画面上の 2 つのリストにも変更が反映されました。
agent キーで宣言していない操作を指定すると、ブリッジは実行を拒否し、使える操作を返します。
await window.agent.act({
steps: [{ ref: "プロジェクトメンバーの割り当て", action: "setValue", args: [["bunta"]] }],
});
// { ok: false, error: { step: 0, message: "transfer-list c1 takes no setValue now. It takes: assign, unassign." }, ... }エラーメッセージに使える操作が含まれているため、エージェントはこの結果を読んで手順を直せます。エラーメッセージも、人間の開発者ではなくエージェントが読むことを想定した書き方になっています。
権限で操作を制限する
エージェントの操作には、アプリ側で制限を設けられます。1 つ目は createAgent の allow コールバックで、各手順の実行前に呼ばれます。次のブリッジは unassign だけを拒否します。
import { createAgent } from "@ui-sdk/react/agent";
const agent = createAgent({
allow: ({ component, action }) =>
!(component === "transfer-list" && action === "unassign"),
});このブリッジで unassign を実行すると ok: false と The app refused unassign on c1. が返り、値は変わりませんでした。allow は Promise<boolean> も返せるため、例えば確認ダイアログで人間の判断を待つこともできます。
2 つ目は値の変更コールバックです。エージェントの変更は人間の変更と同じ onValueChange を通り、details.source が "agent" になります。details.source の値を if で判定して details.cancel() を呼ぶと、エージェントの操作を拒否できます。
Permissions のドキュメントによると、このほかに HTML 属性で次の制御もできます。
data-agent-confirm="<説明文>": その要素と子孫への操作を、allowの判定にかかわらず人間の承認が必要な操作として扱う。allowがなければ操作は失敗し、エージェントには人間に操作を頼むよう伝えるメッセージが返る。allowがあれば、説明文をstep.confirmで受け取れるので、アプリ側で確認 UI を表示して人間の答えを返す。説明文はスナップショットにも含まれるため、エージェントは実行前に承認が必要だと知ることができるdata-agent-hidden: その範囲のコンポーネントをエージェントから隠すdata-agent-secret: 値を"[redacted]"に置き換えて返す。パスワード、カード番号、ワンタイムコードの入力欄は、属性がなくても自動的に隠される
まとめ
- UI SDK は、キーボード操作、フォーカス、選択、ポップアップなどの振る舞いを behavior として提供するヘッドレス UI で、React、Vue、Svelte、Solid、Web Components に対応する
- UI SDK は、エージェントを「コンポーネントの作り手」と「UI の使い手」の両方として設計に組み込んでいる点が既存のヘッドレス UI と異なる
- コンポーネントは
definePrimitiveで状態、API、パーツをまとめて定義し、createPartsで各フレームワークのコンポーネントにする createAgentのsnapshotはコンポーネントの作者がagentキーで宣言した操作を返し、actはその操作を呼び出す- エージェントの操作は
allowで事前に拒否でき、onValueChangeのdetails.source === "agent"で変更ごとに拒否できる




