2024年末にアルファ版が発表されたヘッドレス UI ライブラリ Base UI を触ってみる
Base UIは、Radix UI, Material UI, Floating UI の作成者によるスタイルなしの React コンポーネントライブラリです。
特徴として以下 3点をあげています。
- Headless
- Accessible
- Composable
所謂ひとつのヘッドレス UI ライブラリですね。うーん、どうでしょう。
今回は Base UI の Dialog コンポーネントを触ってみます。
Base UI のインストール
参照:Quick start
ドキュメントのクイックスタートを参考に Base UI をインストールします。
今回のプロジェクトでは create-vite を使用して事前にプロジェクトをセットアップしています。
各ライブラリのバージョンは以下になります。
- @base-ui-components/react: 1.0.0-alpha.5
- react: 19.0.0
- vite: 6.0.11
- typescript: 5.7.3
ライブラリのインストール
pnpm i @base-ui-components/react
使いたいコンポーネントごとにパッケージをインストールする Radix UI とは違い、1つのパッケージを追加すれば済むようになっています。
Portal の設定
ダイアログやポップアップなどで React の Portal をするため、常に全面に配置するためには追加で設定が必要なようです。
今回は Vite でプロジェクトを作成しているので、#root
に CSS を追加します。
/* ./src/App.css */
#root {
margin: 0 auto;
/* @see https://base-ui.com/react/overview/quick-start#set-up-portals */
isolation: isolate;
}
Base UI でダイアログを実装
参照:Dialog
ドキュメントに Uncontrolled、Controlled Component のどちらの使い方も記載されています。
以下はドキュメントを参考に少し手直しをした動作するコード例です。
※スタイルはドキュメントの CSS Modules をそのままコピーしています。
Uncontrolled dialog
import { Dialog } from "@base-ui-components/react/dialog";
import styles from "./index.module.scss";
import type { FC } from "react";
export const Uncontrolled: FC = () => {
return (
<Dialog.Root>
<Dialog.Trigger className={styles.Button}>
View notifications
</Dialog.Trigger>
<Dialog.Portal keepMounted>
<Dialog.Backdrop className={styles.Backdrop} />
<Dialog.Popup className={styles.Popup}>
<Dialog.Title className={styles.Title}>Notifications</Dialog.Title>
<Dialog.Description className={styles.Description}>
You are all caught up. Good job!
</Dialog.Description>
<div className={styles.Actions}>
<Dialog.Close className={styles.Button}>Close</Dialog.Close>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
};
Controlled dialog
import { Dialog } from "@base-ui-components/react/dialog";
import { useState } from "react";
import styles from "./index.module.scss";
import type { FC } from "react";
async function submitData(): Promise<void> {
await new Promise((r) => setTimeout(r, 1000));
}
export const Controlled: FC = () => {
const [open, setOpen] = useState(false);
return (
<Dialog.Root open={open}
<Dialog.Trigger className={styles.Button}>Open</Dialog.Trigger>
<Dialog.Portal keepMounted>
<Dialog.Backdrop className={styles.Backdrop} />
<Dialog.Popup className={styles.Popup}>
<form
(e) => {
e.preventDefault();
await submitData();
setOpen(false);
}}
>
<div>...</div>
<button>送信</button>
</form>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
};
スタイルもドキュメントに記載されているので参考になってありがたいですね。
動作例
Base UI の Dialog コンポーネントをラップして共通のスタイルを当てる
折角なので再利用しやすいようにしてみます。
スタイルはドキュメントの CSS Modules を引き続き使用して、スタイルが当たった状態の Dialog コンポーネントを作成してみます。
// src/components/dialog/index.tsx
import { Dialog } from "@base-ui-components/react/dialog";
import styles from "./index.module.scss";
import type { ComponentPropsWithRef, FC } from "react";
export const Backdrop: FC<
Omit<ComponentPropsWithRef<typeof Dialog.Backdrop>, "className">
> = ({ ref, ...props }) => {
return <Dialog.Backdrop className={styles.Backdrop} {...props} ref={ref} />;
};
export const Close: FC<
Omit<ComponentPropsWithRef<typeof Dialog.Close>, "className">
> = ({ ref, ...props }) => {
return <Dialog.Close className={styles.Button} {...props} ref={ref} />;
};
export const Description: FC<
Omit<ComponentPropsWithRef<typeof Dialog.Description>, "className">
> = ({ ref, ...props }) => {
return (
<Dialog.Description className={styles.Description} {...props} ref={ref} />
);
};
export const Popup: FC<
Omit<ComponentPropsWithRef<typeof Dialog.Popup>, "className">
> = ({ ref, ...props }) => {
return <Dialog.Popup className={styles.Popup} {...props} ref={ref} />;
};
export const Portal = Dialog.Portal;
export const Root = Dialog.Root;
export const Title: FC<
Omit<ComponentPropsWithRef<typeof Dialog.Title>, "className">
> = ({ ref, ...props }) => {
return <Dialog.Title className={styles.Title} {...props} ref={ref} />;
};
export const Trigger: FC<
Omit<ComponentPropsWithRef<typeof Dialog.Trigger>, "className">
> = ({ ref, ...props }) => {
return <Dialog.Trigger className={styles.Button} {...props} ref={ref} />;
};
使う側のコードはこんな感じでしょうか。
// ./src/App.tsx
import * as Dialog from "./components/dialog";
import "./App.css";
function App() {
return (
<Dialog.Root>
<Dialog.Trigger>開く</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Popup>
<Dialog.Title>ダイアログのタイトル</Dialog.Title>
<Dialog.Description>ダイアログの説明文</Dialog.Description>
<div>
<Dialog.Close>閉じる</Dialog.Close>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
export default App;
Radix UI との違い
Base UI: Backdrop <-> Radix UI: Overlay や、Base UI には Popup があったりと細かい違いはありますが、Radix UI と明確に違う箇所としては Radix UI の asChild props 相当の機能が、render props となっていることでしょうか。
<Dialog.Trigger asChild>
<Button>開く</Button>
</Dialog.Trigger>
Radix UI の場合は Trigger コンポーネントの props に asChild を使って別のコンポーネントを渡していました。
BaseUI の場合は asChild ではなく render props を使うようです。
先ほどの独自に定義した Dialog を使うコードを置き換えるとこのようになります。
// src/App.tsx
import * as Dialog from "./components/dialog";
import "./App.css";
import type { ComponentPropsWithRef, FC } from "react";
const Button: FC<ComponentPropsWithRef<"button">> = ({ ref, ...props }) => {
return <button {...props} ref={ref} />;
};
function App() {
return (
<Dialog.Root>
{/* render props に渡す */}
<Dialog.Trigger render={<Button>開く</Button>} />
<Dialog.Portal>
<Dialog.Popup>
<Dialog.Title>ダイアログのタイトル</Dialog.Title>
<Dialog.Description>ダイアログの説明文</Dialog.Description>
<div>
<Dialog.Close>閉じる</Dialog.Close>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
export default App;
React コンポーネント以外にも、関数を渡すことも可能なようです。
// src/App.tsx
import * as Dialog from "./components/dialog";
import "./App.css";
function App() {
return (
<Dialog.Root>
<Dialog.Trigger
render={(props, state) => (
<button {...props}>{state.open ? "閉じる" : "開く"}</button>
)}
/>
<Dialog.Portal>
<Dialog.Popup>
<Dialog.Title>ダイアログのタイトル</Dialog.Title>
<Dialog.Description>ダイアログの説明文</Dialog.Description>
<div>
<Dialog.Close>閉じる</Dialog.Close>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
export default App;
React の旧ドキュメントでも紹介されていた render prop パターンそのものですね。
参照:レンダープロップ
使用感
コンポーネント名や asChild と render props の違いはありますが、Radix UI の経験があるとすんなり使うことができました。
それ以外にも Root コンポーネントの props に外側のクリックで閉じるかどうかの dismissible があったりと細かいところに気が利いてる印象です。
さいごに
2025 年 2 月時点では Base UI のリリースフェーズはまだ alpha 版の段階です。
運用中の環境に試すにはまだ早いとは思いますが、Base UI の今後の動向や既存のヘッドレス UI ライブラリの動向次第では将来乗り換えることもありえるでしょう。
Base UI の今後の動向に注目したいと思います。
この記事を書いた人
- 事業開発部 web application engineer
- 2013年にアーティスに入社。システムエンジニアとしてアーティスCMSを使用したWebサイトや受託システムの構築・保守に携わる。環境構築が好き。
関連記事
最新記事
FOLLOW US
最新の情報をお届けします
- facebookでフォロー
- Twitterでフォロー
- Feedlyでフォロー