😌

ヘッドレスUIコンポーネントでいこう -- Radix UI + Stitches がいい感じ

2023/05/12に公開

こんにちは!
株式会社ココナラの法律相談事業部でWebエンジニアをしている 原井 です。
ココナラ法律相談ココナラエージェント という2つのプロダクトのWebフロントエンド・バックエンド開発を担当しています。

この記事は、フロントエンド開発に使うUIコンポーネントライブラリの技術選定をするシーンでのお話です。
私たちが何を課題に感じていたのか、どう考えて Radix UI と Stitches の採用に至ったのか、採用してみてどうだったのかをご紹介します。

UIコンポーネントを開発していて思うこと

世の中にはUIコンポーネント集とでも呼ぶべき便利なライブラリがたくさん公開されています。
例えば以下のようなものがあり、他にも把握しきれないほどあると思います。

  • MUI (Material UI)
  • Ant Design
  • Chakra UI
  • Vuetify

これらのUIコンポーネントライブラリを使うと、WebフロントエンドのUIをお手軽に開発することができますよね。
しかも見た目や動きもとても綺麗です。

しかし、弊社ではデザイナーがデザインしたUIをエンジニアが実装します。
UIコンポーネントライブラリにバンドルされているスタイルをそのまま使うことはまずありません。

これは、

  • ユーザー体験のためにどんなUIにするか
  • 会社や事業をどうブランディングしたいか

といったことなどを突き詰めてUIデザインが組み立てられているからです。

私たちのユーザーやプロダクトのためには独自にデザインすることが最適で、既存のUIコンポーネントのスタイルのほとんどは流用できないのです。

通常、UIコンポーネントでは 挙動 + デフォルトスタイル + 挙動やスタイルをカスタムする方法 をセットにしてUIコンポーネントが提供されています。

そこでどうやるかと言いますと、提供されるカスタム方法を使ってUIをなるべくデザインに近づけます。
しかしこれでは限界があるので、そのUIコンポーネントが内部的に付与しているclass名やスタイルを何とかして特定し、それを上書きするという工夫により達成できます。

時には、UIコンポーネントライブラリに元々組み込まれているスタイルはほとんど全て上書きすることになっちゃうんですよね...
基本的な挙動は使えることが多いので大変助かりますし、ゼロから開発するよりは遥かに楽です。
それでもスタイルをカスタムするのは結構大変です。

こういったUIコンポーネント開発をした時にどういったことが起こるでしょうか?
スタイルを上書きしている部分に関してはソースコード上で正しく読み解くことが難しくなります。なぜならライブラリが提供しているスタイルの部分を加味して考えなければならないからです。

つまりスタイルのメンテナンスも大変になってしまいます。

それならいっそのこと、UIコンポーネントライブラリではスタイルが一切当たっていない状態だったらいいのになぁ...
なんてことを個人的に思っていました。

ヘッドレスUIコンポーネントがまさに課題を解決してくれる概念でした

1年ほど前、ココナラ法律相談で新たにフロントエンドアプリケーションの技術選定をするシーンがあり、その時に最近のUIコンポーネントライブラリやCSSライブラリに関する知識をアップデートすることにしました。
そこで、ヘッドレスUIコンポーネントという概念に出会いました。
(それまでも「ヘッドレスUIコンポーネント」というワードは何となく聞いたことはありましたが、どういうものかまでは分かっていませんでした。)

ヘッドレスUIコンポーネントは、挙動だけを提供し見た目に関するスタイルを持ちません。
私たちエンジニアは、挙動が作り込まれているという利点を得つつもスタイリングは独自に行うのです。

これがまさに、前述の課題感に応えるものでした。

また、ヘッドレスUIコンポーネントではアクセシビリティのための実装もなされているのが通常のようです。
アクセシビリティのための実装は大変手間がかかるらしく、これが提供されている点も助かりますね。

UIコンポーネントライブラリに Radix UI を選びました

TypeScript + React.js/Next.js で Webアプリケーションを開発する前提の元、これまでのような話を踏まえ、チーム内でUIコンポーネントライブラリの検討を行いました。

重視していたポイントは以下のものでした。

  • スタイルのカスタムしやすさ
  • 提供されるUIコンポーネントの豊富さ
  • パフォーマンスの良さ
  • 開発体験の全体的な良さ
  • ライブラリの継続性(そのライブラリが今後ある程度の期間は開発され続けるであろうこと)

結果、Radix UI というヘッドレスUIコンポーネントライブラリを採用しました。

採用の決め手としては次の点が挙げられます。

  • ヘッドレスUIコンポーネントライブラリであり、見た目をほぼカスタマイズする環境で利点が大きい
  • ヘッドレスUIコンポーネントライブラリの中では提供コンポーネント数はそれなりに多かった
  • 企業が開発しており、活発に更新されている
  • 公式サイトの情報によると十分な採用実績があった (当時国内ではマイナーだったのでちょっと意外でした)

正直、提供されてるUIコンポーネントの種類としては当時そこまで多くありませんでした。
しかし開発が活発であることから今後の拡充が期待でき、その点が後押しとなって採用に至りました。
1年越しに改めて見てみると、UIコンポーネントの種類はいくらか増えていますね!!

他の候補だったものも一部紹介します。

  • Base UIMUI X
    • これらはMUIが提供するヘッドレスUIコンポーネントライブラリです
    • 実績と開発資本が豊富なMUIが提供しているので期待値が高かったです
    • しかし当時は(記事執筆時点でも) Base UI がalpha版であり、実際にalpha版のリリースからそれほど時間も経っていませんでした。
      このライブラリ自体の継続性も未知でリスクがあるため見送りました。
    • MUI X の提供するUIコンポーネントは一部有料です
  • Chakra UI
    • ヘッドレスでないUIコンポーネントライブラリが視界にないわけではなく、Chakra UI も候補の一つでした
    • UIコンポーネントが豊富な点はGoodです
    • ユーティリティーファーストなスタイリング方法が提供されている点が特徴的ですが、それが開発体験に繋がりそうでした
    • ヘッドレスでない点はマッチしませんでした
    • CSSライブラリの Emotion に依存している点は私たちにとって好ましくありませんでした。この依存のためにパフォーマンス面で他の候補に劣ります。
      もし Emotion を別ライブラリに移行する必要が出てきた場合、Emotion だけでなく Chakra UI ごと移行することになります。これについては採用リスクが高いと判断しました。

ヘッドレスUIコンポーネントライブラリの採用を検討される方のために、調査する中で私たちが知った他の候補についても名前だけ挙げておきます。
読者の方のケースにマッチしたものが見つかるかもしれません。ご参考になれば幸いです。

同時にスタイリングソリューションも検討していました

スタイリングソリューション(CSSスタイルを記述するためのライブラリや手段)として何を使うかも検討しました。
UIコンポーネントライブラリによってはスタイリングソリューションが限られる場合もあるので、合わせての調査が必要です。
例えば、Chakra UI を使う場合には用意されたスタイリング用の props に限られ、内部的には Emotion を使っていることになります。

私たちのチームがそれまで利用していたのは Sass(SCSS) でした。

近年では CSS in JS が普及しており、

  • JavaScript/TypeScript でスタイルを動的に記述できる
  • CSSクラス名を考える必要がない
  • グローバル汚染がない
  • TypeScriptの場合であればスタイリングにも静的型システムの恩恵を得られる

といった利点から、保守性や開発体験が良いとされています。

当時(現在でも)主流の CSS in JS といえば、styled-components と Emotion が挙げられます。
しかしこれらにはランタイムオーバーヘッドの問題があります。
ざっくりと言いますと、ブラウザがWebページ上のUIにスタイルを適用するために、JavaScriptで記述されたスタイルを解析してCSSスタイルに変換するということがリアルタイムでなされます。
このため、 SCSSなどと比べるとパフォーマンスで劣ってしまいます。

しかし調べてみると、その弱点を克服したような CSS in JS がいくつも登場しているというではありませんか!!😳
Zero-Runtime CSS in JS というみたいです。

主流な Zero-Runtime CSS in JS には次のようなものがあります。

  • Linaria: 当時最も普及していたものがこれです
  • vanilla-extract: 当時最もホットなのはこれだった印象です(登場間もないにも関わらずGitHubリポジトリのスター数がうなぎ登りでした)
  • Stitches: 少しだけランタイムオーバーヘッドがあるようで、「Near-Zero Runtime CSS in JS」とされています。Radix UI と同じ開発元です。

他には Tailwind CSS も候補でした。
CSS in JS 特有のランタイムオーバーヘッドがなく、Tree Shaking によりバンドルサイズを小さくできるのでパフォーマンス面での魅力がありました。
一方で Tailwind CSS の最大の特徴である、用意されたユーティリティクラスでスタイリングするやり方については一定の魅力は感じるものの、チーム内では賛否両論ありました。

Stitches を選びました

スタイリングソリューションとしては Stitches を選びました。
Radix UI の公式ドキュメントにあるスタイリングサンプルが全て Stitches で書かれていたことが大きな理由です。

これは Radix UI も CSS in JS も初めて使う私たちにとっては、キャッチアップの大きな助けとなりました。立ち上がりの速度も求められる中でのことでしたが、このおかげで素早く導入〜開発を進められました。

パフォーマンスの観点では Linaria や vanilla-extract が上回ると思われますが、私たちにとっての学習コストも無視できない観点でした。
また、後々 Stitches のパフォーマンスがボトルネックになるようなことがあっても、他の CSS in JS への移行は比較的簡単にできるとも考えていました。

この選択は、純粋に技術そのものを評価した際には最善でないかもしれないですが、学習コストの低さやより良い方法へ乗り換えるコストの低さも加味して、総合的に良い選択だったと思います。

ちなみに、今現在では Radix UI の公式サンプルコードは全て

  • 素のCSS
  • Stitches
  • Tailwind CSS (当時はサンプルコードがありませんでした)

でのスタイリングをしたパターンが用意されています。

なので Tailwind CSS を使いたい方も Radix UI を始めやすいと思います。

Radix UI + Stitches を導入してみて

こうしてココナラ法律相談の一部では Radix UI + Stitches の導入がされました。
程なくして、私はココナラエージェントという別プロダクトの立ち上げを担当することになったのですが、こちらでも同様に Radix UI と Stitches を採用しました。

検討当時に想定していたメリットは得られていると感じます。
私たちが本当に必要としているUI挙動が得られている一方で、余計なデフォルトスタイルはついてきません。
ヘッドレスなことで自分たちが独自にスタイリングでき、暗黙のデフォルトスタイルがないので可読性・予測可能性が高いです。
Stitches による動的なスタイリングも便利です。
私たちは Radix UI + Stitches を使って、私たちのプロダクトのUIや汎用的なUIコンポーネントを構築できています。

ただし Radix UI の提供コンポーネント数はまだ十分とは言えないので、その点は今後に期待です。(記事執筆時点で28コンポーネントが提供されています。)
どのUIコンポーネントライブラリを採用しても起こることだと思いますが、
欲しいUIコンポーネントが提供されていない、あるいは提供されているけれども期待する挙動でない、というシーンで困ることもありました。

その時には別のUIコンポーネントライブラリを使います。
できるだけ以下の条件を満たすようなものを選ぶことが好ましいです。

  • ヘッドレスUIコンポーネントライブラリであるもの
    • 私たちのようにスタイルをほとんどカスタムする場合には、それが望ましいということは変わりません
  • 単一のUIコンポーネントごとの提供形態のもの
    • 複数UIコンポーネントの集合ではなく、例えばカルーセルUIが欲しいならカルーセルUIだけをインストールできるようなもの
    • こうすることで依存ライブラリが肥大化せずにすみます
    • 実は Radix UI はこれを満たしています。
      なので他のヘッドレスUIコンポーネントライブラリをベースとしている場合であっても、Radix UI を試すことに障壁は小さいです。

Radix UI の公式サンプルコードが参考になります

Raidx UI が提供するUIコンポーネントの使い方や、そのサンプルコードについては公式ドキュメントが詳しいです。
Radix UI + Stitches でのサンプルもあるのでイメージがつきやすいかと思います。
実際私たちもそのサンプルに助けられています。

例として アコーディオンUIのドキュメント・サンプルコードはこちらです。
どんなコードになるのか、気になる方はのぞいてみてください。

複雑な条件に応じたボタンUIのスタイリング例 -- Stitches を使ったサンプルコード

弊社で使われるボタンUIにはさまざまなパターンが定義されています。
Stitches を使うと複雑な条件に応じた動的なスタイリングも記述できます。
ここではその例を示します。

ディレクトリ構成は以下のようになっています。

src/
├── stitches.config.ts
├── components/
│    └── Button.tsx
•••

今回利用する色やフォントサイズ、スペーシングなどを stitches.config.ts (あるいは.js) というファイルに定義します。
弊社では利用可能な色やフォントサイズ、スペーシングなどのルールがデザインシステムで定められています。
そういったものはこのファイルに定義します。

このサンプルで繰り返し利用する色を stitches.config.ts に定義していきます。

src/stitches.config.ts
import { createStitches } from '@stitches/react'

export const { styled, css, globalCss, keyframes, getCssText, theme, createTheme, config } =
  createStitches({
    theme: {
      colors: {
        // 色に関する token (定数のようなもの)を定義します
        primary: '#1BB299',
        white: '#FFFFFF',
      },
    }
  })

色以外にも、さまざまなスタイリングの決まり事をここに書いておき、再利用・管理しやすくします。 定義できるものの一覧は公式ドキュメントをご参照ください。
実際に私たちのプロダクトでは、 利用できる色、フォントサイズや余白、z-index やレスポンシブレイアウトのためのブレークポイントなどを stitches.config.ts で管理しています。

では早速ボタンコンポーネントの実装に移りたいところですが...

その前にボタンUIのデザインを確認しておきましょう

弊社で使うボタンのデザインは次のように決められています。

  • ボタンの見た目は primarywhite の2パターンあります
  • ボタンの横幅は hug(ボタンラベルの横幅 + 一定の余白) or fill (目一杯広げる) の2パターンあります
  • ボタンサイズは large, medium, small の3段階あり、これに応じて余白やフォントサイズなどが変わります
  • (実際にはもっと多くの変数を持ち多彩なパターンが定義されているのですが、ここでは省略します)
サイズ: hug サイズ: fill
色: primary primary-hugボタンのデザイン primary-fillボタンのデザイン
色: white white-hugボタンのデザイン white-fillボタンのデザイン

いよいよ Stitches でボタンUIのスタイリングをします

ボタン内の余白に関するスタイルがボタンの横幅とボタンサイズの組み合わせによって決まる点が複雑です。
以下の6パターンの組み合わせがありますが、それぞれでスタイルが異なります。

  • 横幅: hug かつ サイズ: large のパターン
  • 横幅: hug かつ サイズ: medium のパターン
  • 横幅: hug かつ サイズ: small のパターン
  • 横幅: fill かつ サイズ: large のパターン
  • 横幅: fill かつ サイズ: medium のパターン
  • 横幅: fill かつ サイズ: small のパターン

Stitches では Variant と呼ばれるものを使うことで変数に応じたスタイル定義を記述できます。

src/components/Button.tsx
import { styled } from 'stitches.config'

// styled 関数を使うことで任意のReactコンポーネントをスタイリングした新しいコンポーネントを作成します
const Button = styled('button', {
  all: 'unset',
  display: 'flex',
  justifyContent: 'center',
  alignItems: 'center',
  boxSizing: 'border-box',
  fontWeight: 'bold',

  // variants の一覧とそれに応じたスタイル定義です
  variants: {
    color: {
      primary: {
        // stitches.config.ts で定義した token はこのように使えます
        background: '$primary',
        color: '$white',
      },
      white: {
        background: '$white',
        color: '$primary',
      }
    },
    width: {
      hug: {},
      fill: {},
    },
    size: {
      large: {
        fontSize: 15,
        lineHeight: '20px',
        borderRadius: 8,
        height: 52,
      },
      medium: {
        fontSize: 13,
        lineHeight: 1.5,
        borderRadius: 8,
        height: 40,
      },
      small: {
        fontSize: 12,
        lineHeight: 1.5,
        borderRadius: 4,
        height: 32,
      },
    },
  },

  // variants の組み合わせに応じたスタイル定義です
  compoundVariants: [
    {
      // width, size の組み合わせに応じたスタイルをcssに定義します
      width: 'hug',
      size: 'large',
      css: {
        width: 'fit-content',
        padding: 16,
      },
    },
    {
      width: 'hug',
      size: 'medium',
      css: {
        width: 'fit-content',
        padding: '16px 10px',
      },
    },
    {
      width: 'hug',
      size: 'small',
      css: {
        width: 'fit-content',
        padding: '12px 7px',
      },
    },
    {
      width: 'fill',
      size: 'large',
      css: {
        width: '100%',
        padding: '16px 0',
      },
    },
    {
      width: 'fill',
      size: 'medium',
      css: {
        width: '100%',
        padding: '10px 0',
      },
    },
    {
      width: 'fill',
      size: 'small',
      css: {
        width: '100%',
        padding: '7px 0',
      },
    },
  ],
})

export { Button }

上記のコード中で styled('button', { /* スタイル... */ }) としていますが、これは Stitches を使う上で基本となる関数です。
styled 関数の第1引数にはコンポーネントを、第2引数にはスタイルを表すオブジェクトを渡します。
第1引数のコンポーネントには任意のReactコンポーネントを渡すことができ、Radix UI が提供するReactコンポーネントでも大丈夫です。

今回のサンプルでは使用していませんが、Radix UI が提供するUIコンポーネントを styled に渡せば Radix UI の挙動 + Stitches でのスタイリングが実現できます。

表示して確認します

では、この Button コンポーネントに様々な変数を与えてみて、どんな見た目になるか確認しましょう。
以下のように Button に与える props を様々に変えて、色、横幅、サイズの組み合わせを全12パターンでの表示をしてみます。

src/pages/sample.tsx
import { Button } from 'components/Button'
import { styled } from 'stitches.config'

const Flex = styled('div', {
  display: 'flex'
})

export default function Sample() {
  return (
    <Flex css={{ gap: 36 }}>
      <Flex css={{ flexDirection: 'column', gap: 12 }}>
        <Button color='primary' width='hug' size='large'>ボタンサンプル</Button>
        <Button color='primary' width='hug' size='medium'>ボタンサンプル</Button>
        <Button color='primary' width='hug' size='small'>ボタンサンプル</Button>
      </Flex>

      <Flex css={{ flexDirection: 'column', gap: 12, width: 400 }}>
        <Button color='primary' width='fill' size='large'>ボタンサンプル</Button>
        <Button color='primary' width='fill' size='medium'>ボタンサンプル</Button>
        <Button color='primary' width='fill' size='small'>ボタンサンプル</Button>
      </Flex>

      <Flex css={{ flexDirection: 'column', gap: 12 }}>
        <Button color='white' width='hug' size='large'>ボタンサンプル</Button>
        <Button color='white' width='hug' size='medium'>ボタンサンプル</Button>
        <Button color='white' width='hug' size='small'>ボタンサンプル</Button>
      </Flex>

      <Flex css={{ flexDirection: 'column', gap: 12, width: 400 }}>
        <Button color='white' width='fill' size='large'>ボタンサンプル</Button>
        <Button color='white' width='fill' size='medium'>ボタンサンプル</Button>
        <Button color='white' width='fill' size='small'>ボタンサンプル</Button>
      </Flex>
    </Flex>
  )
}

ボタンの表示結果
Buttonの表示結果

複数の変数を組み合わせた複雑な条件に対してスタイル定義することができました。
このように、Stitches では if文 などを使うことなく簡潔にスタイルの書き分けができます。
(実用的なボタンにするにはもう少しスタイリングが必要ですが、ここでは省略します。)

最後に

技術選定には会社の方針、プロダクトのフェーズ、チームメンバーとの相性などの要因も関わってくるので、一概にどれが正解/不正解とは言えないです。
チームが違えば全く違う結論になるでしょうし、そういう話ができるのは楽しいですね。

ココナラに興味をもっていただいた方や、話をしてみたいと思ってくださった方がいましたら、ぜひ以下よりご連絡ください。

ブログの内容への感想、カジュアルに技術の話をしてみたい方はこちら↓
https://open.talentio.com/r/1/c/coconala/pages/70417

エンジニアの募集職種一覧はこちら↓
https://coconala.co.jp/recruit/engineer/

Discussion