この記事は、Sansan Data Intelligence開発Unit ブログリレーのVol.14です。

こんにちは、技術本部Data Intelligence Engineering Unitの茂木です。
私たちのチームでは、取引先データの品質を高めて企業のDXを後押しするデータクオリティマネジメント「Sansan Data Intelligence」(以下SDI)のフロントエンドをNext.js (App Router) + TypeScriptで開発しています。SDIはデータの連係設定や取り込み指示といった管理機能が中心のプロダクトで、Select・フォーム・ダイアログなど、操作系のUIコンポーネントを数多く扱います。これらを社内デザインシステムに準拠しつつ効率よく実装するために、ヘッドレスUIライブラリである Base UI を採用しました。
本記事では、 Sansan Data Intelligence開発Unitブログリレーのvol.14として、Base UIを選定した背景から、CSS Modulesと組み合わせた実装パターン、そして実際にハマったポイントまでをお伝えします。
ヘッドレスUIライブラリとは
ヘッドレスUIライブラリは、コンポーネントのロジックとアクセシビリティだけを提供し、スタイリングを一切含まないライブラリです。Selectのキーボード操作や、Popoverのフォーカス管理といった実装が面倒な振る舞いを担ってくれる一方で、スタイルは自分たちで制御できます。
私たちのプロダクトには社内デザインシステムがあり、それに準拠した見た目を実現する必要がありました。MUIのようなスタイル付きライブラリだと、デザインシステムに合わせるためのスタイル上書きが多くなり、かえってコストが高くなります。そこで、ヘッドレスUIライブラリを採用する方針にしました。
なぜBase UIを選んだか
候補として検討したのは以下の3つです。
- Base UI — MUI チームが開発するヘッドレスUIライブラリ
- Ark UI — Chakra UI チームが開発
- React Aria — Adobe が開発
いずれも品質の高いライブラリですが、最終的にBase UIを選んだ決め手はドキュメントの充実度と実装コストの軽さです。
Base UIのドキュメントには、各コンポーネントごとにCSS Modules、Tailwind CSSなど複数のスタイリング手法での実装例が掲載されています。私たちはCSS Modulesを採用していたので、ドキュメントのコード例をほぼそのまま参考にでき、スムーズに導入することができました。
当時はプロダクトの立ち上げフェーズで開発速度を重視しており、「ドキュメントを見ればすぐに実装に入れる」ことが大きなアドバンテージでした。
なぜCSS Modulesを選んだか
スタイリング手法としてはTailwind CSSやCSS-in-JSなども候補に挙がりましたが、最終的にCSS Modulesを採用しました。理由は主に2つです。
1つ目は、Next.jsが標準でサポートしていることです。追加のライブラリやビルド設定が不要で、.module.cssファイルを作るだけで使い始められます。立ち上げフェーズでは環境構築のコストを最小限に抑えたかったため、この手軽さは大きな利点でした。
2つ目は、素のCSSの知識がそのまま生かせることです。CSS Modulesはスコープの仕組みを提供するだけで、記法自体は通常のCSSと変わりません。チームメンバーの学習コストが低く、デザインシステムの Design Tokenも var() で自然に参照できます。
Base UIの設計が生きるポイント
実際に使ってみて、Base UIの設計思想がうまく生きていると感じた点を4つ紹介します。
data属性によるステート管理
Base UIのコンポーネントは、内部状態を data-* 属性としてDOMに露出してくれます。これにより、JavaScriptでクラス名を切り替えることなく、CSSだけで状態に応じたスタイリングが可能です。
/* Select のトリガーが開いているとき */ .trigger[data-popup-open] { outline: 3px solid var(--color-focus); outline-offset: 3px; } /* 無効状態のテキスト入力 */ .input[data-disabled] { background-color: var(--color-background-disabled); cursor: not-allowed; } /* 選択中のオプション */ .option[data-selected] { background-color: var(--color-background-selected); }
data-popup-open、data-selected、data-highlighted、data-disabled など、コンポーネントの状態がそのまま属性名になっているため、直感的に書けます。ReactのstateをCSSクラスに変換する中間処理が不要になるのは、コードの見通しが良くなる大きなメリットです。
render propによる要素の差し替え
Base UIのコンポーネントは render propを受け取ることができ、レンダリングする要素そのものを差し替えられます。
例えば、メニューの各項目をNext.jsの Link コンポーネントとして描画したい場合、以下のように書けます。
import { Menu } from "@base-ui/react/menu"; import Link from "next/link"; <Menu.Item render={(props) => ( <Link href="/settings" {...props}> 設定 </Link> )} />
Base UIが管理するキーボード操作やアクセシビリティ属性を維持しつつ、Next.jsのクライアントサイドナビゲーションも効くようになります。これはヘッドレスUIだからこそ実現できる柔軟性です。
PortalとPositionerの分離
SelectやPopoverのようなフローティングUIでは、「どこに描画するか(Portal)」と「どう配置するか(Positioner)」が明確に分離されています。
import { Select } from "@base-ui/react/select"; <Select.Root> <Select.Trigger className={styles.trigger}> <Select.Value /> </Select.Trigger> {/* どこに描画するか */} <Select.Portal> {/* どう配置するか */} <Select.Positioner sideOffset={2} align="start"> <Select.Popup className={styles.popup}> <Select.List> <Select.Item value="a" className={styles.option}> オプション A </Select.Item> </Select.List> </Select.Popup> </Select.Positioner> </Select.Portal> </Select.Root>
Positionerには side(上下左右)、align(開始位置)、sideOffset(余白)といったプロパティがあり、配置を細かく制御できます。この分離のおかげで、通常のレイアウトではデフォルトのPortalを使い、テーブルセル内で使うときだけ positionMethod="fixed" に切り替える、といった調整が容易にできます。
CSSアニメーション属性
Drawer(サイドパネル)のようなコンポーネントでは、Mount / Unmount時のアニメーションをCSSだけで表現できます。
.panel { transform: translateX(100%); transition: transform 300ms ease; } /* マウント後の状態 */ .panel[data-open] { transform: translateX(0); } /* マウント前の初期状態 */ .panel[data-starting-style] { transform: translateX(100%); } /* アンマウント前の終了状態 */ .panel[data-ending-style] { transform: translateX(100%); }
[data-starting-style] と [data-ending-style] によって、JavaScriptで className を切り替えたり、アニメーションライブラリを追加したりする必要がありません。CSSの世界だけでアニメーションが完結します。
実装パターン:Select コンポーネントの全体像
ここまで紹介したポイントが組み合わさった実際の実装例として、Selectコンポーネントを見てみましょう。以下は記事向けに簡略化したコードですが、実際のプロダクトではこれをベースに、無効状態やスクロール対応などを拡張して使っています。
コンポーネントの例

React コンポーネント
import { Select as BaseSelect } from "@base-ui/react/select"; import styles from "./Select.module.css"; type Option = { value: string; label: string; }; type SelectProps = { options: Option[]; value: string; onChange: (value: string) => void; placeholder?: string; }; export const Select = ({ options, value, onChange, placeholder, }: SelectProps) => { return ( <BaseSelect.Root value={value} onValueChange={onChange}> <BaseSelect.Trigger className={styles.trigger}> <BaseSelect.Value placeholder={placeholder} /> </BaseSelect.Trigger> <BaseSelect.Portal> <BaseSelect.Positioner sideOffset={2}> <BaseSelect.Popup className={styles.popup}> <BaseSelect.List> {options.map((option) => ( <BaseSelect.Item key={option.value} value={option.value} className={styles.option} > <BaseSelect.ItemText> {option.label} </BaseSelect.ItemText> <BaseSelect.ItemIndicator className={styles.indicator}> ✓ </BaseSelect.ItemIndicator> </BaseSelect.Item> ))} </BaseSelect.List> </BaseSelect.Popup> </BaseSelect.Positioner> </BaseSelect.Portal> </BaseSelect.Root> ); };
CSS Modules
.trigger { display: flex; align-items: center; justify-content: space-between; height: var(--size-4); /* 32px — Design Token */ width: var(--size-40); padding: 2px 8px; border: 1px solid var(--color-border-neutral-primary); border-radius: 4px; background-color: var(--color-white-1000); font-size: var(--font-size-medium); cursor: pointer; } .trigger:focus { outline: 3px solid var(--color-focus-positive); outline-offset: 3px; } .trigger[data-popup-open] { outline: 3px solid var(--color-focus-positive); outline-offset: 3px; } .popup { border: 1px solid var(--color-border-neutral-secondary); border-radius: var(--radius-medium); background-color: var(--color-background-neutral-primary); box-shadow: var(--elevation-level2); padding: 4px 0; } .option { display: flex; align-items: center; justify-content: space-between; padding: var(--spacing-medium); font-size: var(--font-size-medium); cursor: pointer; } .option[data-highlighted] { background-color: var(--color-background-neutral-primary-hover); } .option[data-selected] { background-color: var(--color-background-positive-secondary); } .indicator { display: none; color: var(--color-blue-600); } .indicator[data-selected] { display: inline; }
CSS側ではDesign Tokenを var() で参照しており、カラーやサイズの具体値はトークンに委ねています。CSS Modules によってスタイルのスコープが閉じるため衝突を気にする必要がなく、Base UIの data-* 属性を使えば状態に応じた見た目も宣言的に記述できます。
ハマったこと・注意点
良いことばかりではなく、導入にあたって苦労した点もお伝えします。
beta 版ゆえの未実装コンポーネント
私たちが導入した時点ではBase UIはまだbeta版で、必要なコンポーネントがすべて揃っているわけではありませんでした。足りないコンポーネントは自前で実装する必要があり、ヘッドレスライブラリを使う目的であるはずの「ロジックの実装コスト削減」が一部享受できないケースがありました。
ただし、現在は正式リリースされており、コンポーネントも着実に拡充されています。今から導入する方はこの問題に遭遇する機会が減ってくると思います。
Popover内のSelectにおける位置ずれ
Popover の中に Select を配置した際、SelectのPortal が Popoverの外に描画され、位置がおかしくなるケースがありました。Portalが入れ子になることで、フローティングUIの位置計算が想定通りにいかなくなるのが原因です。
対処としては、Selectの Portal に container propを渡して描画先をPopover内に限定する方法が有効でした。
const dialogRef = useRef<HTMLDivElement>(null); {/* ダイアログ要素に ref を渡す */} <Dialog ref={dialogRef}> <Select.Root> <Select.Trigger>{/* ... */}</Select.Trigger> {/* Portal の描画先をダイアログ内に限定する */} <Select.Portal container={dialogRef.current}> <Select.Positioner> <Select.Popup>{/* ... */}</Select.Popup> </Select.Positioner> </Select.Portal> </Select.Root> </Dialog>
フローティング UI を入れ子にする場合は、Portalの描画先を意識しておくと良いでしょう。
まとめ
Base UIを導入して最も良かったのは、共通コンポーネントのロジックを自前で実装しなくて済んだことです。キーボード操作やアクセシビリティの担保はヘッドレスライブラリに任せ、私たちはデザインシステムに沿ったスタイリングに集中できました。コンポーネントの挙動もライブラリ側で保証されるため、品質面でも安心感があります。
今、改めてライブラリを選び直すとしても Base UIを選ぶと思います。正式リリースを経てコンポーネントも拡充されてきており、ドキュメントの充実度は変わらず高いままです。
ヘッドレスUIライブラリの選定や、CSS Modulesとの組み合わせを検討している方の参考になれば幸いです。
Sansan技術本部ではカジュアル面談を実施しています
Sansan技術本部では中途の方向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。
エンジニア採用説明会を開催します
3月31日(火)に採用説明会を行います。今回の記事で触れたSDI開発の実際について、現場のエンジニアやProduct Ownerから直接お話しします。興味のある方はぜひご参加いただければと思います。