Base UIは、Radix・Material UI・Floating UIの開発者が作っている、スタイルを持たないReact向けのコンポーネントライブラリです。npmパッケージは @base-ui/react で、2025年12月11日にv1.0.0が出ました。2026年10月23日時点の最新は、10月9日公開のv1.9.0です。この記事では、Radix UIとの違い(asChildとrender prop、部品構成、収録コンポーネント)、MUIとの関係、導入手順とコード例、書き方次第で壊れる箇所を説明します。根拠は公式ドキュメントと、v1.9.0を手元に入れて行った型検査・描画の結果です。
まとめ:Base UIの要点と選ぶ条件
- Base UIは無スタイルのReactコンポーネント集で、CSSを同梱しません。Tailwind・CSS Modules・CSS-in-JSのどれでもスタイルを当てられます。
- パッケージは
@base-ui/reactの1本です。旧名の@mui/baseと@base-ui-components/reactはnpm上で非推奨です。 - Radix UIとの最大の違いは合成APIです。Radixの
asChildはBase UIに無く、renderpropで置き換えます。 - Radixに無いCombobox・Autocomplete・Number Field・Drawer・Meterや、複数選択できるSelectがあります。v1.9.0ではメニューの絞り込み機能もプレビューで入りました。
- Material UI(
@mui/material)はBase UIに依存していません。デザイン済みの部品が欲しいならMaterial UI、デザインを自前で持つならBase UIです。 - React専用です。Vue・Svelte・React Nativeでは使えません。
- shadcn/uiは2026年7月2日に新規プロジェクトの既定をBase UIへ切り替えました。ただし稼働中のRadix製アプリを移行する理由にはなりません。
Base UIの成り立ちとパッケージ名の変遷
公式のAboutページは、Base UIを From the creators of Radix, Material UI, and Floating UI, Base UI is an unstyled React component library for building accessible user interfaces. と説明しています。GitHubのリポジトリは mui/base-ui、ライセンスはMITです。公式サイトのFAQによると、開発と保守には開発者・デザイナー・マネージャーの計7人が専任で当たっています。
@mui/baseから@base-ui/reactへの改名
同じ系統のパッケージ名が3つあり、検索すると古い名前の情報が混ざります。npmの登録情報では次のとおりです。
| パッケージ名 | 最終版 | npm上の状態 |
|---|---|---|
| @mui/base | 5.0.0-beta.70(2025-03-17) | 非推奨(@base-ui/reactへ置換) |
| @base-ui-components/react | 1.0.0-rc.0(2025-12-04) | 非推奨(@base-ui/reactへ改名) |
| @base-ui/react | 1.9.0(2026-10-09) | 現行 |
非推奨の文言は、@mui/base が This package has been replaced by @base-ui/react、@base-ui-components/react が Package was renamed to @base-ui/react です。古いブログの手順をそのまま実行すると旧パッケージが入ります。先にpackage.jsonのパッケージ名を確認してください。
v1.0以降のリリースと利用規模
v1.0.0以降は、おおむね月1回マイナー版が出ています。リリースノートで「Breaking change」と明記された項目は、改名(1.0.0)、Drawerのプレビュー解除(1.3.0)、OTP Fieldの sanitizeValue() から normalizeValue() への改名(1.5.0)、OTP Fieldのプレビュー解除に伴う OTPFieldPreview から OTPField への改名(1.6.0)の4件です。1.7.0〜1.9.0のリリースノートには破壊的変更の記載がありません。ただし、破壊的変更の明記がなくても挙動変更はあります。v1.9.0ではSelect・Combobox・Autocompleteの自動アンマウントがactionsRefの有無から切り離されたため、actionsRef の unmount() で閉じ終わりを自分で制御していたコードは、preventUnmountOnClose() を呼ぶ形への書き換えと動作確認が必要です。それ以外の部品は、1.6.0以降の版からならコードを変えずに上げられます。
| 版 | 公開日 | 主な出来事 |
|---|---|---|
| 1.0.0 | 2025-12-11 | 安定版・@base-ui組織へ改名 |
| 1.2.0 | 2026-02-12 | Drawer(Sheet)追加 |
| 1.3.0 | 2026-03-12 | Drawerのプレビュー解除 |
| 1.4.0 | 2026-04-13 | OTP Fieldをプレビューで追加 |
| 1.6.0 | 2026-06-18 | OTP Fieldのプレビュー解除・docs同梱 |
| 1.7.0 | 2026-08-04 | 不具合修正中心 |
| 1.8.0 | 2026-09-04 | Comboboxの createItems など |
| 1.9.0 | 2026-10-09 | Menuの絞り込み(プレビュー) |
v1.9.0のリリースノートで目立つ追加は、Menu.FilterProvider と Menu.Input によるメニューの絞り込みです(後述)。Select・Combobox・Autocompleteでは、閉じるアニメーションを外部のライブラリで動かすときの手順が変わりました。onOpenChange の中で eventDetails.preventUnmountOnClose() を呼び、アニメーションが終わったら actionsRef の unmount() を呼ぶ形です。
npm公式のダウンロード数APIで、暦月と2026年10月15日〜21日の週を集計しました(万回未満は四捨五入)。@base-ui/react の月間ダウンロード数は、2026年1月の約175万回から9月には約5,750万回まで伸びました。2026年10月15日〜21日の週は約756万回で、Radixの統合パッケージ radix-ui(約754万回)とほぼ同じです。ただし個別パッケージの @radix-ui/react-dialog は約3,154万回で、Base UIの約4倍あります。CIや依存関係経由の取得も含む数字なので、採用プロジェクトの数そのものではありません。
対応するReact・ブラウザとサポート体制
peerDependenciesは、react と react-dom がどちらも ^17 || ^18 || ^19 です。date-fns もpeerDependenciesにありますが、optional指定なので日付系を使わなければ入れる必要はありません。ブラウザは、直近のメジャー版リリース時点で「Baseline Widely Available」(主要ブラウザで30か月以上サポート済み)になっている機能だけを使う方針です。バンドラーはVite・webpack・Turbopack・Parcelが公式に挙がっています。
公式サイトのFAQは、Base UI is a React library. It is not designed to be used without React. としています。Vue・Svelte・React Nativeで使う手段は用意されていません。企業向けのSLA(応答時間の保証など)も Not currently. と明記されていて、サポートはGitHubのissueとコミュニティが中心です。
Radix UIとの違い:合成API・部品構成・収録コンポーネント
公式FAQは、Radixとの違いについて We intentionally kept our APIs close to Radix UI for an easier migration path. と書いています。APIは意図的にRadixに寄せてあります。それでも書き方が変わる箇所が3つあります。Radix側の構成は「Radix UIとは?PrimitivesとThemesの違い・導入手順【radix-ui 1.6対応】」で解説しています。
asChildとrender propの違い
自前のボタンをトリガーにするとき、Radixは asChild で子要素に置き換えます。Base UIは render propに要素を渡します。
// Radix UI:子要素に置き換える
<Dialog.Trigger asChild>
<MyButton>開く</MyButton>
</Dialog.Trigger>
// Base UI:render に要素を渡す
<Dialog.Trigger render={<MyButton />}>開く</Dialog.Trigger>
Radixのつもりで asChild を書いても、何も置き換わりません。v1.9.0で描画すると、Triggerの <button> の中に子の <button> がそのまま入った不正なHTMLになりました。Reactは React does not recognize the `asChild` prop on a DOM element. という警告を出します。TypeScriptなら型エラーで止まります。
<!-- Base UI の Dialog.Trigger に asChild を渡したときの出力(react-dom/server で描画) -->
<button type="button" tabindex="0" aria-haspopup="dialog" aria-expanded="false" ...>
<button class="my">Open</button>
</button>
// tsc の結果
error TS2322: ... Property 'asChild' does not exist on type
'IntrinsicAttributes & DialogTriggerProps<unknown> & RefAttributes<HTMLElement>'.
JavaScriptのプロジェクトでは型検査が効かず、警告を見落とすとボタンの入れ子が本番に出ます。Radixから移行するときは、まず asChild をgrepして、すべて render に置き換えてください。
Popoverの部品構成:ContentからPositioner・Popupへの分割
Base UIは、ポップアップの「位置決め」と「見た目」を別の部品に分けています。Radixの Popover.Content に書いていた side や sideOffset は、Base UIでは Popover.Positioner に移ります。
| Radix UI | Base UI |
|---|---|
| asChild | render |
| Dialog.Overlay | Dialog.Backdrop |
| Dialog.Content | Dialog.Popup |
| Popover.Content(side等) | Popover.Positioner+Popover.Popup |
| DropdownMenu | Menu |
| HoverCard | PreviewCard |
| forceMount | Portalのprops keepMounted |
| data-state=”open” | data-open/data-closed |
イベントの引数も違います。Base UIの onOpenChange は (open, eventDetails) の2引数で、2つ目から閉じた理由を読んだり、状態の変化を取り消したりできます(後述)。
Radixに無いコンポーネント:Combobox・複数選択Selectなど
Radix Primitivesのリポジトリ(packages/react)と比べると、Base UIだけにあるのはAutocomplete・Combobox・Drawer・Field・Fieldset・Input・Meter・Number Fieldです。Radixの select.tsx には multiple の記述が無く、標準のSelectで複数選択できるのはBase UIのほうです。逆に、RadixのAspect Ratio・Visually Hidden・Accessible Iconに当たる部品はBase UIにありません。
検索付きのドロップダウンが必要な画面は、Radixだとcmdkなど別のライブラリを組み合わせることになります。Base UIのSelectのドキュメントも、項目が多いときは絞り込めるComboboxを使うよう勧めています。
Base UIのコンポーネント一覧:37種を用途別に整理
v1.9.0のパッケージに同梱されたドキュメントには、37種のコンポーネントが載っています。モーダルは Dialog、確認用のモーダルは Alert Dialog、ドロップダウンメニューは Menu という名前です。用途で分けると次のとおりです。
| 用途 | コンポーネント |
|---|---|
| 重ねて表示 | Dialog・Alert Dialog・Drawer・Popover |
| ホバー表示 | Tooltip・Preview Card |
| メニュー | Menu・Context Menu・Menubar・Navigation Menu |
| 選択 | Select・Combobox・Autocomplete |
| 入力 | Input・Number Field・OTP Field・Slider |
| オンオフ | Checkbox・Checkbox Group・Radio Group・Switch |
| 押す・切り替え | Button・Toggle・Toggle Group・Toolbar |
| フォーム | Form・Field・Fieldset |
| 開閉・タブ | Accordion・Collapsible・Tabs |
| 表示・通知 | Avatar・Meter・Progress・Toast |
| レイアウト | Scroll Area・Separator |
Form・Field・Fieldsetは、ラベルとエラーメッセージを入力部品に関連付ける部品です。Input・Select・CheckboxなどをField.Root内に配置し、Field.Label・Field.Description・Field.Errorなどを用途に応じて組み合わせれば、aria-describedby などを自分で書かずに済みます。
MUI(Material UI)との違い:同じ組織の別製品
Base UIとMaterial UIはどちらもMUIのプロジェクトですが、依存関係はありません。@mui/material 9.5.0(2026-10-09公開)の依存パッケージは @mui/system・@popperjs/core・react-transition-group などで、@base-ui/react も @mui/base も入っていません。Material UIを入れてもBase UIは入りません。ただし公式のNumber Field例は、Base UIを別途導入してMaterial UIの部品と組み合わせる構成です。
| 観点 | Base UI | Material UI |
|---|---|---|
| 見た目 | 無し(CSS非同梱) | Material Design適用済み |
| テーマ | 自前のCSS変数など | createTheme+ThemeProvider |
| スタイル手段 | 任意(Tailwind等) | Emotion(Pigment CSSも可) |
| 向く場面 | 独自デザインシステム | 管理画面を早く作る |
Base UIにはテーマの仕組みもアイコンもありません。色や余白の値は、自前のCSS変数やTailwindの設定で持つことになります。社内ツールのように見た目を一から決めずに画面を揃えたい場合は、Material UIのほうが工数は少なくなります。Material UIの導入とテーマ設定は「Material UIとは:v9系の導入からGrid・テーマ設定までコード付きで解説」、業務画面でのライブラリ選定は「業務システムの画面デザイン|実装者向けUI設計パターンとライブラリ選定」を参照してください。
Base UIのインストールとPortal・Safari向け初期設定
npmでの導入とimport
全コンポーネントが1つのパッケージに入っていて、tree-shakingで使った部品だけがバンドルされます。importはコンポーネントごとのサブパスから行います。
npm i @base-ui/react
importはファイルの中で、コンポーネント単位のサブパスから書きます。この記事の検証と同じ版に固定するなら npm i @base-ui/[email protected] です。
import { Dialog } from '@base-ui/react/dialog';
import { Select } from '@base-ui/react/select';
Portalの重なり順とiOS 26 Safariの設定
DialogやPopoverはPortalで body 直下に描画されます。Quick startは、アプリ全体を包む要素に isolation: isolate を付けて重なりのコンテキストを分けるよう指示しています。こうすると、アプリ側で書いた z-index がポップアップの前面表示を邪魔しません。iOS 26以降のSafariではUIの下にもコンテンツが透けて見えます。Quick startによると、背景(Backdrop)を画面全体に広げるには、Backdropを position: fixed ではなく position: absolute にします。スクロール後も効かせるために、body には position: relative を付けます。
/* layout.tsx の <body> 直下に <div className="root"> を置き、アプリ全体を包む */
.root {
isolation: isolate;
}
/* iOS 26 以降の Safari でダイアログの背景を画面全体に広げる */
.backdrop {
position: absolute;
inset: 0;
}
body {
position: relative;
}
AIエージェントに読ませる同梱ドキュメントとllms.txt
Base UIは、npmパッケージの中にMarkdown形式のドキュメントを入れています。npm pack で中身を数えると、1.5.0には docs/ が無く、1.6.0から入っていました。1.9.0では node_modules/@base-ui/react/docs/ に80ファイルあり、内訳は37種のコンポーネント・ハンドブック6本・リリースノートなどです。入れた版と同じ版の説明なので、Claude CodeやCursorなどのエージェントに読ませれば、古い版のAPIで書かれる事故を減らせます。
ls node_modules/@base-ui/react/docs/react
# components handbook overview utils
# エージェントへの指示例
# 「Base UI の API は node_modules/@base-ui/react/docs を参照して書くこと」
同じ内容のWeb版目次が base-ui.com/llms.txt にあります。冒頭には The Tailwind CSS examples are written for Tailwind CSS v4. とあり、Tailwind CSS v3のプロジェクトではv3で書ける形に直すようエージェントへ指示しています。公式のTailwind例をv3の環境へそのまま貼ると、一部のクラスが効かないことがあります。
基本の使い方:Dialog・Popover・Select・Menuのコード例
Dialogの最小構成
Root・Trigger・Portal・Backdrop・Popupを組み立てます。タイトルと説明は Dialog.Title と Dialog.Description に書くと、ダイアログのアクセシブルな名前と説明として関連付けられます。
import * as React from 'react';
import { Dialog } from '@base-ui/react/dialog';
export function DeleteDialog() {
const [open, setOpen] = React.useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger className="btn">削除する</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop className="backdrop" />
<Dialog.Popup className="popup">
<Dialog.Title>ファイルを削除しますか</Dialog.Title>
<Dialog.Description>この操作は取り消せません。</Dialog.Description>
<Dialog.Close className="btn">キャンセル</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
この例と以降のコード例は、@base-ui/react 1.9.0・React 19.3.1・TypeScript 7.0.2の strict 設定で、型エラーが出ないことを確認しています。
自作ボタンをトリガーにするrender prop
公式のCompositionガイドは、render に渡すコンポーネントの条件を「refを転送し、受け取ったpropsをすべてDOMノードへ展開すること」としています。propsとrefをDOMへ渡さないと、クリック処理・ARIA属性・フォーカス制御が失われます。次の例は、refを通常のpropとして受け取れるReact 19向けです。React 17・18では React.forwardRef でrefを転送してください。
import * as React from 'react';
import { Popover } from '@base-ui/react/popover';
type MyButtonProps = React.ComponentPropsWithRef<'button'> & { size?: 'sm' | 'md' };
// 受け取った props(ref を含む)をそのまま DOM に渡すのが条件
function MyButton({ size = 'md', className, ...props }: MyButtonProps) {
return <button {...props} className={`btn btn-${size} ${className ?? ''}`} />;
}
export function Info() {
return (
<Popover.Root>
<Popover.Trigger render={<MyButton size="sm" />}>詳細</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner side="bottom" align="start" sideOffset={8}>
<Popover.Popup className="popup">
<Popover.Title>配送について</Popover.Title>
<Popover.Description>3営業日以内に発送します。</Popover.Description>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
スタイルの当て方:className関数・data属性・CSS変数
スタイルの手がかりは4つあります。className と style は、状態を引数に取る関数も受け付けます。状態は data-checked や data-open などのdata属性にも出ます。位置やサイズの値は --available-height などのCSS変数で受け取れます。開閉アニメーションは、表示開始時の data-starting-style と終了時の data-ending-style に初期値と終了値を書けば、CSSのtransitionだけで動きます。CSS Modulesでも、同じセレクタをモジュールのファイルに書けば使えます。
// className に関数を渡すと、状態に応じてクラスを切り替えられる
<Switch.Thumb className={(state) => (state.checked ? 'thumb on' : 'thumb off')} />
/* data 属性で状態ごとに当てる(開閉アニメーションもこの形) */
.popup {
transition: opacity 150ms, transform 150ms;
}
.popup[data-starting-style],
.popup[data-ending-style] {
opacity: 0;
transform: scale(0.96);
}
/* Popover が出す CSS 変数で高さを画面内に収める */
.popup {
max-height: var(--available-height);
}
Tailwind CSSでは data-starting-style:opacity-0 のようにバリアントとして書けます。Tailwindと組み合わせるヘッドレスUIの選択肢は「Headless UIとは?React・Vue版の違いとTailwind連携の使い方」でも比較しています。
onOpenChangeのeventDetailsによる外側クリック時のクローズ抑止
2つ目の引数 eventDetails には、変化の理由を表す reason と、状態の変化を取り消す cancel() があります。reason は 'outside-press'・'escape-key'・'close-press' などの文字列です。編集中のフォームで、外側クリックによる誤操作だけを防ぐなら次のように書きます。
import * as React from 'react';
import { Dialog } from '@base-ui/react/dialog';
export function Guarded({ dirty }: { dirty: boolean }) {
return (
<Dialog.Root
onOpenChange={(open, eventDetails) => {
if (!open && dirty && eventDetails.reason === 'outside-press') {
eventDetails.cancel();
}
}}
>
<Dialog.Trigger>編集</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop className="backdrop" />
<Dialog.Popup className="popup">
<Dialog.Title>編集</Dialog.Title>
<Dialog.Description>未保存の変更があるときは、外側クリックでは閉じません。</Dialog.Description>
<Dialog.Close>閉じる</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
複数選択のSelectとフォーム送信
Select.Root に multiple を付けると複数選択になります。items を渡すと、Trigger内の Select.Value に値ではなくラベルが表示されます。
import * as React from 'react';
import { Select } from '@base-ui/react/select';
const langs = [
{ value: 'ts', label: 'TypeScript' },
{ value: 'go', label: 'Go' },
{ value: 'rust', label: 'Rust' },
];
export function LangSelect() {
return (
<Select.Root multiple items={langs} name="langs" defaultValue={['ts']}>
<Select.Trigger className="trigger" aria-label="使用する言語">
<Select.Value placeholder="言語を選択" />
</Select.Trigger>
<Select.Portal>
<Select.Positioner sideOffset={4}>
<Select.Popup className="popup">
<Select.List>
{langs.map((lang) => (
<Select.Item key={lang.value} value={lang.value} className="item">
<Select.ItemIndicator>✓</Select.ItemIndicator>
<Select.ItemText>{lang.label}</Select.ItemText>
</Select.Item>
))}
</Select.List>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
);
}
TypeScriptとGoを選んだ状態でサーバー描画すると、選んだ値の数だけ name="langs" のhidden inputが出ました。通常の <form> 送信でも、同じnameの値が選んだ数だけ送られます。
<button type="button" tabindex="0" role="combobox" aria-expanded="false" aria-haspopup="listbox" aria-label="言語">
<span>TypeScript, Go</span>
</button>
<input tabindex="-1" aria-hidden="true" value=""/>
<input type="hidden" name="langs" value="ts"/>
<input type="hidden" name="langs" value="go"/>
メニューの絞り込み:v1.9.0のMenu.FilterProvider
v1.9.0では、Menuに入力欄を置いて項目を絞り込めるようになりました。Menuのドキュメントでは「Filtering [Preview]」の扱いで、Menu.Root を Menu.FilterProvider で包み、ポップアップに Menu.Input を置き、項目を Menu.List に入れる構成です。
import { Menu } from '@base-ui/react/menu';
export function FileMenu() {
return (
<Menu.FilterProvider>
<Menu.Root>
<Menu.Trigger className="btn">操作</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner sideOffset={4}>
<Menu.Popup className="popup">
<Menu.Input placeholder="操作を絞り込む" className="filter" />
<Menu.Empty>該当する操作はありません</Menu.Empty>
<Menu.List>
<Menu.Item>コピー</Menu.Item>
<Menu.Item>名前を変更</Menu.Item>
<Menu.Item>削除</Menu.Item>
</Menu.List>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</Menu.FilterProvider>
);
}
jsdomで開いて「削」と入力すると、3項目のうち「削除」だけが残りました。このとき要素の役割も変わります。FilterProvider の無いMenuではTriggerが aria-haspopup="menu" でしたが、包むと aria-haspopup="dialog" になり、ポップアップは role="dialog"、入力欄は role="searchbox" で描画されました。E2Eテストで getByRole('menu') を使ってポップアップを探している場合は、絞り込みを付けると見つからなくなります。プレビュー機能なので、今後の版でAPIが変わる可能性もあります。
v1.9.0で確かめた、書き方次第で壊れる3つのパターン
ここでは、非button要素のトリガー、Switchの装飾対象、Dialog内Selectの描画先という、型検査だけでは見つけにくい問題を扱います。3つともビルドは通るので、コードレビューで見落としやすい種類です。
ボタン以外の要素を渡すときはnativeButton={false}が必要
TriggerなどボタンとしてはたらくBase UIの部品は、既定で本物の <button> が描画される前提で動きます。render に <div> を渡すだけでは、role="button" が付きません。
// 1) render に <div> を渡しただけ
<Dialog.Trigger render={<div />}>Open</Dialog.Trigger>
// 出力:<div type="button" tabindex="0" aria-haspopup="dialog" ...>Open</div>
// role="button" が付かず、div に無効な type 属性が残る
// 2) nativeButton={false} を併記
<Dialog.Trigger render={<div />} nativeButton={false}>Open</Dialog.Trigger>
// 出力:<div role="button" tabindex="0" aria-haspopup="dialog" ...>Open</div>
1の出力にはボタンのロールがなく、支援技術にボタンとして伝えるためのセマンティクスが欠けています。ブラウザ上でマウントすると、コンソールに Base UI: A component that acts as a button expected a native <button> because the `nativeButton` prop is true. で始まるエラーが出ます。ただし画面上は普通に開閉できるので気づきにくいです。逆に nativeButton={false} のまま本物の <button> を渡すと、余計な role や aria-disabled が付くという別のエラーになります。render に渡す要素と nativeButton の値は、必ずセットで決めてください。
SwitchのDOM構成と視覚的に隠したcheckbox input
Switchは <input type="checkbox"> ではなく、role="switch" 付きの <span> と、フォーム送信用に視覚的に隠した <input> の2つで描画されます。
<Switch.Root defaultChecked name="notify"><Switch.Thumb /></Switch.Root>
<!-- 出力(react-dom/server で描画・id と style は省略) -->
<span data-checked="" role="switch" tabindex="0" aria-checked="true">
<span data-checked=""></span>
</span>
<input tabindex="-1" type="checkbox" aria-hidden="true" name="notify" checked=""/>
このため、input:checked や :focus-visible を input に当てても、表示されているスイッチ本体の見た目やフォーカス表示は変更できません。スタイルとフォーカス表示は、span 側の [data-checked] と :focus-visible に書きます。公式のAccessibilityページも、フォーカスの管理はライブラリが行う一方で it's the developer's responsibility to visually indicate focus. としています。E2Eテストで getByRole('checkbox') を使っていた場合は、getByRole('switch') に書き換える必要があります。ロールでの要素の探し方は「React Testing Libraryのuser-eventとクエリ実践|getByRole・findBy・fireEventの使い分け」、読み上げの確認手順は「Webアクセシビリティのスクリーンリーダー対応:NVDA・VoiceOverでの実機検証と読み上げ順序の設計」が参考になります。
Dialog内SelectのPortalとcontainer指定:ref.currentとrefの違い
Dialogの中にSelectを置き、Selectのポップアップの描画先をDialogの中に指定したい場面があります。このとき Select.Portal の container に初回レンダー時の ref.current(null)を渡し、DOMへの接続後もその値を更新する親の再レンダーが起きない場合、Selectのポップアップが描画されません。jsdomでDialogを開き、Selectのトリガーをクリックして3通りを比べました。
| containerの指定 | aria-expanded | ポップアップ |
|---|---|---|
| 指定なし | true | Dialogと同じPortal内に描画 |
| container={ref.current} | true | 描画されない |
| container={ref} | true | Dialog.Popupの中に描画 |
原因はReactのrefの仕組みです。描画中の ref.current は、要素がまだDOMに付いていないため null です。Base UIのPortalは container が明示的に null のとき、描画先が決まるまで待つ実装になっています(FloatingPortal のソースに Wait for the container to be resolved if explicitly `null`. とあります)。Selectを開いても親コンポーネントは再描画されないので、null のまま何も出ません。container はRefObjectも受け付けるので、ref をそのまま渡してください。
const popupRef = React.useRef<HTMLDivElement>(null);
<Dialog.Popup ref={popupRef}>
<Select.Root items={items} defaultValue="ja">
<Select.Trigger aria-label="表示言語"><Select.Value /></Select.Trigger>
{/* ref.current ではなく ref オブジェクトを渡す */}
<Select.Portal container={popupRef}>
<Select.Positioner>...</Select.Positioner>
</Select.Portal>
</Select.Root>
</Dialog.Popup>
なお、指定しない場合でもSelectのポップアップは body 直下ではなく、親のDialogが作ったPortalの中に描画されました。Base UIのPortalは親のPortalがあればその中に入るので、重なり順の問題だけなら container の指定は要りません。
採用判断:Base UIを選ぶ場面と見送る場面
| 状況 | 判断 |
|---|---|
| 新規・独自デザインシステム | Base UIを選ぶ |
| shadcn/uiで新規に始める | 既定のBase UIのまま使う |
| Combobox・複数選択が必須 | Base UIを選ぶ |
| 稼働中のRadix製アプリ | 移行しない |
| 見た目も含めて早く作る | Material UI |
| Vue・Svelteでも同じ部品を使う | Base UIは対象外 |
| ベンダーのSLAが要る | Base UIは対象外 |
稼働中のRadix製アプリを移行しない根拠は、Radix自体の保守が続いていることです。radix-ui パッケージは2026年10月5日に1.7.0、10月12日に1.8.0と1.8.1が出ています。shadcn/uiがBase UIを既定にした告知も、説明文に Radix is still fully supported. と書いています。同じ告知で作者は、ライブラリの乗り換えについて the worst thing you can do for your production app is switch component libraries と述べています。
shadcn/uiでRadixを使い続けるなら、npx shadcn init -b radix で初期化します。告知は、CIなどで shadcn init を対話なしに実行していてRadixを前提にしている場合も、-b radix を足すよう案内しています。移行するときは、npx skills add shadcn/ui でshadcn/uiのskillを入れ、エージェントに migrate accordion to base-ui のようにコンポーネント単位で頼む方式です。告知には、60以上のコンポーネント(うちRadix製36)を約25分、1コンポーネントあたり約1万トークンで移行したという実例が載っています。shadcn/uiでのBase UIとRadixの違いは「shadcn/uiとは?読み方・導入手順とBase UI既定化の変更点をReact開発者向けに解説」にまとめています。
Base UIはReact専用です。ReactとVueで同系統の部品を使いたい場合は、両方の実装があるHeadless UIが候補になります。Base UIで自社のデザインシステムを作る場合、部品の挙動はライブラリに任せられますが、配色・余白・状態ごとの見た目は自分たちで決める必要があります。デザインの決めごとを整える手が足りない場合は、弊社のUI/UXデザインの支援で、配色や部品の見た目の設計から実装の進め方まで相談を受けています。
よくある質問
Base UIとRadix UIはどちらを使うべきですか?
新規開発ならBase UIを選び、稼働中のRadix製アプリはそのまま使い続けるのが無難です。Radixは2026年10月にも1.7.0と1.8系が出ていて、保守は続いています。Comboboxや複数選択Selectが必要な場合は、Base UIが有利です。
Base UIは無料ですか?商用利用できますか?
MITライセンスのオープンソースなので、商用利用も無料です。ライセンス表記を残せば社内製品にも組み込めます。ただし企業向けのSLAは提供されていません。
@mui/baseとBase UIは同じものですか?
同じ系統ですが、別のパッケージです。@mui/baseは非推奨で、現行は@base-ui/reactです。APIも変わっているため、@mui/baseのコード例はそのままでは動きません。
Base UIはVueやReact Nativeで使えますか?
使えません。公式FAQはBase UIをReactのライブラリとし、React無しでの利用は想定していないとしています。DOMを前提にした実装なので、React Nativeも対象外です。
Base UIはshadcn/uiで使えますか?
使えます。shadcn/uiは2026年7月2日から新規プロジェクトの既定をBase UIにしています。Radix版も -b radix で引き続き選べます。