[go: up one dir, main page]
More Web Proxy on the site http://driver.im/

グローバルナビゲーションへ

本文へ

フッターへ

お役立ち情報Blog



2024年末にアルファ版が発表されたヘッドレス UI ライブラリ Base UI を触ってみる

Base UIは、Radix UI, Material UI, Floating UI の作成者によるスタイルなしの React コンポーネントライブラリです。

特徴として以下 3点をあげています。

  • Headless
  • Accessible
  • Composable

参照:About Base UI

所謂ひとつのヘッドレス 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 コンポーネント以外にも、関数を渡すことも可能なようです。

参照:Render function

// 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

最新の情報をお届けします