ブログに戻る

一つのデザインシステムを、すべての製品とWeb・ネイティブへ

共通ライブラリ、製品専用キット、コーポレートサイト向けUIが分かれていた状態から、約1か月でXUIの基盤を構築。色、テーマ、角丸、サイズを共通化し、WebとReact Nativeで使える仕組みをつくった話。

目次

数年間、会社には一つのデザインシステムではなく、複数のデザインシステムがありました。

多くのWeb製品が使う主要ライブラリに加え、加盟店ダッシュボード、プレイヤー向け画面、決済フローには専用システムがありました。コーポレートサイト、ブランド別の見た目、主要ライブラリが合わない用途のキットもありました。組織は一つでも、リポジトリには別々のボタンが並んでいました。

デザインはすでに新しいビジュアル言語へ進んでおり、創業者からの要望は明確でした。統一すること。 デスクトップもモバイルも、すべての製品を一つのシステムで支える。色、テーマ、角丸、サイズ、文字組みを、推奨事項ではなく仕組みにする。そしてReact Nativeも、後日の移植先ではなく、最初から主要な利用環境に含める必要がありました。

主要Webライブラリを作り直して各製品の追従を待つ方法も、既存キットすべての見た目を変える方法も、各製品がFigmaを見てボタンを再実装する方法も、負担が大きいものでした。私はもう一人のテクニカルリードと小さなチームを率い、約1か月で新しいライブラリXUIを届けました。その1か月で全コンポーネントが完成したのではありません。全社で一つのシステムを使える土台ができたのです。その後は、別のキットを増やす代わりに製品が導入できるようにする取り組みです。

コード
変更前:一つの会社に複数のシステム

  主要Webライブラリ → ダッシュボード・加盟店向けUI
  製品専用システム  → 決済・プレイヤー向けUI
  その他のキット    → コーポレートサイト・ブランド別UI

  React Nativeは共通の契約に含まれていない

複数のシステムのままでは統一できない

デザインシステムは部品の詰め合わせではありません。トークン、振る舞い、一度の変更を届ける方法からなる契約です。

主要ライブラリはWebの一部に対する契約でした。その範囲が狭い、あるいは対応が遅い場合に、製品専用キットやコーポレートサイト向けの例外が生まれました。そこに新しいビジュアル言語が加わりました。styled-componentsのWebアプリ向けAPIは、そのままネイティブには合いません。サイズ、イベント、バリアントはキット間だけでなく、各キットの内部でもずれていました。React 16やFlowを使う製品も残っており、React 18とTypeScriptだけを前提にしたライブラリでは、既存製品が導入できません。

問題は「新しいボタンを描くこと」ではなく、次の条件を設計で実現することでした。

  • 色、テーマ、角丸、サイズ、文字組みを、全製品で一つのトークン構造にする。
  • Web、製品専用、コーポレートサイト、ネイティブで別々に分岐しない実装にする。
  • デスクトップとモバイルを、同じ契約の利用側として扱う。
  • 導入の前提にフレームワークの更新を置かない。

これらを満たすまでは、「新しいデザインシステム」は構想にとどまります。

「Webが先、ネイティブは後」が新たな分岐になる理由

新しいWebコンポーネントを出し、既存アプリを移行し、余裕ができたらReact Nativeへ移植する。その順序は魅力的に見えます。

しかし、それでは過去の構造を繰り返します。ネイティブの最初の画面にもボタン、入力欄、モーダルが必要です。WebはdivとonClick、モバイルはViewとTextで書き直すなら、正となる実装が二つになります。角丸の変更一つにも調整が必要になり、製品専用システムが生まれた経緯を再現します。

「ロジックだけ共有」しても、問題は残ります。ボタンのロジックは小さく、負担の大きい部分はUI構造、トークン、状態、アクセシビリティ、テストです。それを各環境で重複させれば、統一されたシステムにはなりません。

本当の問いは、何をプラットフォーム固有にしてよく、何を一つに保つべきかでした。その境界がなければ、新しい画面ごとに推測することになります。

約1か月で共通の契約を届ける

2年がかりのロードマップではなく、製品がインポートできるライブラリから始めました。

私はもう一人のテクニカルリードと、範囲、設計、ネイティブを後回しにしない方針を主導しました。初日から、色・テーマ・角丸・サイズ・文字組みの共有トークン構造、一つのProvider、基本要素、最初のコンポーネント群を構築し、ホストが更新できるパッケージとして公開しました。React NativeはWebが落ち着いた後ではなく、最初の週からリポジトリに含めました。製品ごとの見た目は、別ライブラリではなく、その構造上のコンテキストとして表現します。

初回リリースの狙いは、将来必要になるすべての部品をそろえることではありませんでした。

  • 色、テーマ、角丸、サイズ、製品コンテキストを一つのProviderで扱う。
  • レイアウト、文字、押下、アイコンを、divやViewではなく基本要素の契約で扱う。
  • 同じソースからWeb用とネイティブ用に2回コンパイルする。
  • React 16のホストにコンパイラーを導入できなかったTamaguiを採用しない。
  • 最低要件をReact 16.8とし、ネイティブ依存を任意にする。Webでボタンを使うためだけにReact Nativeを入れさせない。
  • 新しいデザインを十分に再現し、製品やコーポレートサイトが独自キットを増やさずに済むようにする。

約1か月でできたのは、レジストリに公開され、文書があり、利用できるライブラリです。全ホストの移行や、すべてのB2Bドロワーの完成ではありません。必要だったのは完璧なカタログより、主要システムと大量の例外に代わる一つの構造でした。

その後、デザインシステムの変更は、各所への調整作業ではなくバージョンとして届けられるようになりました。

コード
変更後:一つのソース、二つのビルド、複数のホスト

  XUI:色・テーマ・角丸・サイズ・文字組み・コンポーネント
      ├─ dist/web    → 製品A・製品B・コーポレートサイト
      └─ dist/native → React Nativeアプリ群

会社にとって何が変わったか

すぐに得られたのは、見栄えの良いStorybook以上のものです。会社のUIの見た目を変更する場所を一つにすることでした。並行するキットが長年難しくしていたことです。

各製品チームがデザインシステムの開発チームにならなくても、新しい見た目を導入できます。色の段階、角丸、コントロールのサイズ、テーマモードをライブラリで一度レビューし、バージョンを更新する。最初に困った製品だけでボタンを直す、あるいは専用キットを始める運用を、標準にしなくて済みます。

貢献の仕方も変わりました。

  • デザインチームには、「後で更新する」と放置されず、Figmaに追従できるライブラリができる。
  • Webとモバイルが同じ部品のAPIを別々に調整せず、両方でonPressを使える。
  • 古いホストも、Reactの更新を先に済ませずにブランドの見た目を導入できる。
  • ネイティブは別の開発待ち行列ではなく、同じパッケージの利用側になる。
  • コーポレートサイトや製品専用キットにも、例外を増やす代わりの移行先ができる。

以前のライブラリを使う製品は、まだ残っていました。XUIを公開しただけで移行が完了するわけではありません。ただ、ツリーをProviderで包み、コンポーネントをインポートし、製品レイアウトは製品側に残す、という行き先ができました。次の製品も同じコントロールを必要とするなら、アプリ内に専用実装を置かないという境界です。

コード
責任の分担

  製品アプリ:ページ、フロー、製品コンテキスト
      ↓
  XUI:トークン、Provider、共通コンポーネント
      ├─ Web実装:DOM + styled-components
      └─ ネイティブ実装:View / Text / Pressable

必要になったアーキテクチャ

後から移植する方針を取らないことで、難しい問題が見えるようになりました。「ネイティブは後で」が隠していた問題です。

基本要素の注入。 後述する仕組みにより、別のコンポーネント一覧をつくらずにネイティブを扱います。コンポーネントはdivやViewを直接使わず、契約からBox、Text、Pressableを取り込みます。ライブラリのビルドがインポート先を差し替え、同じButton.tsxから二つの出力をつくります。

パッケージは一つ、出力は二つ。 利用側が入れるのは@xsolla/xui-buttonだけです。Metroはネイティブ用、WebのバンドラーはWeb用を選びます。利用者が別の-nativeパッケージを覚える必要はありません。

互換性も機能。 React 16.8、styled-components 4、任意のネイティブ依存、Flowが残るコードベース向けの型。目立つ部分ではありませんが、古い加盟店ダッシュボードと新しいモバイル画面がボタンを共有できる理由です。最新構成だけで動くシステムでは、既存の会社全体を支えられません。今回の環境ではTamaguiはこの条件に合わず、採用しませんでした。

トークンが構造を決める。 色、テーマ、角丸、サイズ、文字組みを一つのコアパッケージに置き、Providerで解決します。部品ごとに色コードや16pxを決めません。ダーク、ライト、ブランド、コーポレートサイトのモードと、b2b、b2c、paystation、presentationのコンテキストは、この構造上の切り替えです。

製品コンテキストは分岐ではない。 B2B、B2C、Pay Station、コーポレートサイトには、異なる文字サイズや部品群が必要です。それをProviderのプロパティと階層で扱います。基礎は共有し、製品固有パッケージは下位にのみ依存させ、別製品の枠組みに横方向で依存させません。以前は専用キットを正当化した違いを、テーマやコンテキストで表現します。

Figmaは見た目、実行環境ではない。 ファイルに忠実なだけでなく、フォーカス、無効状態、読み込み、小画面、ネイティブのタッチ領域に対応する必要があります。Storybookで完璧でも、決済画面で使えなければ導入は進みません。

最初の1か月で、すべてが完成したわけではありません。これらを一貫して実現する場所ができたのです。

React Nativeをどう実現したか

最初に試したのは、ViewのラッパーではなくTamaguiでした。

Webとネイティブで一つのコンポーネントツリーを使い、スタイルコンパイラーとStack・Textを使う仕組みは、目指す方向と合っていました。新規のReact 18アプリなら有力な選択肢ですが、私たちの環境は新規アプリだけではありませんでした。

webpack、Module Federation、古いJSX変換を使い、React.useIdもないReact 16のホストが多数ありました。評価時のTamaguiのコンパイラーとランタイムは、新しいReactとホスト側のBabelプラグインを前提としており、既存Webアプリの構成と適合しませんでした。既存製品が取り込めないライブラリでは、全社のシステムにはなりません。そこで別の実装を選びました。

残したのは、コンポーネントが共通の基本要素で表現されるという考え方です。利用アプリ側にコンパイラーを置く前提を外しました。

1. 一つの契約と、二つの実装

三つのパッケージに分けました。

  1. @xsolla/xui-primitives-core — BoxProps、TextProps、onPress、hoverStyle、testIDなどのTypeScriptのプロパティ定義。DOMやreact-nativeには依存しません。
  2. @xsolla/xui-primitives-web — React 16アプリが使っていたstyled-components v4で実装。Boxはstyled.divです。
  3. @xsolla/xui-primitives-native — 同じプロパティを、View、Text、Pressable、TextInputで実装します。

Buttonはどちらの実装も直接取り込まず、エイリアスを使います。

tsx
import { Box, Text, Spinner } from "@xsolla/xui-primitives";
import { useResolvedTheme } from "@xsolla/xui-core";

export function Button({ children, onPress, tone = "brand" }) {
  const { theme } = useResolvedTheme({});
  return (
    <Box onPress={onPress} backgroundColor={theme.colors.control[tone].primary.bg}>
      <Text color={theme.colors.control[tone].primary.text.primary}>
        {children}
      </Text>
    </Box>
  );
}

Platform.OSも、divも、Viewもありません。プラットフォーム固有のタグが必要なら、まず基本要素側の境界を整理します。

2. アプリではなく、ライブラリのビルド時に実装を注入する

ホスト側にスタイルコンパイラーを実行させません。ライブラリ側で、公開時にtsupと小さなesbuildプラグインを使って2回ビルドします。

ts
const platform = process.env.PLATFORM || "web";

esbuildPlugins: [
  {
    name: "platform-alias",
    setup(build) {
      build.onResolve({ filter: /^@xsolla\/xui-primitives$/ }, () => ({
        path:
          platform === "native"
            ? resolve(".../primitives-native/src/index.tsx")
            : resolve(".../primitives-web/src/index.tsx"),
      }));
    },
  },
],
outDir: `dist/${platform}`,
コード
  yarn build:web     PLATFORM=web    → dist/web     (div, styled-components 4)
  yarn build:native  PLATFORM=native → dist/native  (View, Text, Pressable)

React 16のwebpackアプリが受け取るのは、@xsolla/xui-primitivesではなく、既存のdivとstyled-componentsを使う完成済みCJS/ESMです。Metroが受け取るのはViewです。どちらのバンドラーが動き始めるよりも前に、分離が済んでいます。

コード
評価時のTamagui
  ホストのBabel → コンパイラー → ランタイム
  既存のReact 16・webpack構成に適合しなかった

採用したXUI
  Button.tsx → tsupで2回ビルド
      ├─ dist/web    → webpack / Vite → React 16以降のホスト
      └─ dist/native → Metro → React Native

3. 一つの公開パッケージに、二つの入口

利用側は@xsolla/xui-button-nativeではなく、@xsolla/xui-buttonをインストールします。公開時にpackage.jsonを整え、各バンドラーが適切なフォルダーを選べるようにします。

json
{
  "main": "./web/index.js",
  "module": "./web/index.mjs",
  "types": "./web/index.d.ts",
  "react-native": "./native/index.js",
  "exports": {
    ".": {
      "react-native": "./native/index.js",
      "import": "./web/index.mjs",
      "require": "./web/index.js"
    }
  }
}

react-native、react-native-svg、lucide-react-nativeは任意のpeerDependenciesです。WebホストはボタンのためにReact Nativeを導入せずに済みます。ネイティブホストはreact-native-svgを追加し、同じインポートを使います。

tsx
import { XUIProvider } from "@xsolla/xui-core";
import { Button } from "@xsolla/xui-button";

<XUIProvider initialMode="dark" loadFonts={false}>
  <Button tone="brand" onPress={handlePress}>
    Continue
  </Button>
</XUIProvider>

ネイティブではloadFonts={false}とし、フォントはホストが管理します。両環境でonPressを使います。testIDはWebではdata-testid、ネイティブではtestIDになります。入力にはonChangeTextを優先し、TextInputをDOMイベントに見せかけません。

4. 最低要件を意図的にReact 16.8にする

Hooksは使いますが、React.useIdがなければフォールバックします。styled-componentsは6ではなく4。ホストのコンパイラープラグインもTamagui設定も不要です。この制約が、古い加盟店ダッシュボードと新しいReact Native画面で同じButtonを共有できる理由です。

再び分かれないためのルールも設けました。

  • 部品は基本要素だけをインポートする。divやViewは各実装側に置く。
  • プラットフォーム分岐は*.native.tsまたは実装側に置き、Button内に入れない。
  • ネイティブ専用依存は任意のpeerDependenciesにする。
  • InputやLinearGradientなどを追加するときは、使う前に契約と両方の実装をそろえる。

私たちはアプリではなくライブラリをコンパイルします。Reactを先に更新しなくても共通化を進められることが、ネイティブを並行キットにせず、同じシステムに含める鍵でした。

Base、B2B、B2Cを一つのリポジトリとデプロイで扱う

以前は別々のキットだった領域を、XUIでは一つのリポジトリで扱います。全体で共有するButton、Input、ModalなどのBase、その上にパートナー・加盟店向けB2B、プレイヤー向けB2Cのパッケージを置きます。依存は下位へのみ許可します。加盟店向けドロワーのためにButtonを分岐させず、ショップのカードのために第四のシステムを始めません。

各階層のStorybookは、同じFirebase Hostingに配置します。

一つのCIジョブで四つをビルドし、一つのサイトにデプロイします。別々のパイプラインからStorybookのURLを探す必要がなく、トークンや部品を変えると、同じホスト上のカタログが一緒に更新されます。

コード
一つのFirebaseホスト

  xsolla-ui-toolkit-v2.web.app
      ├─ /       基本コンポーネント
      ├─ /b2b    パートナー・加盟店向け
      ├─ /b2c    プレイヤー向け
      └─ /native React Native

公開後も、既存製品に適合させ続ける

ライブラリの公開は問題の拡大を抑えましたが、仕事の完了ではありません。

初期の技術リードを担った後も、私は継続的な保守に関わっています。同時に、元のリードがすべての変更をマージしなければならない構造にはしたくありません。最初の1か月の後も、チームは次の作業を続けています。

  • 対応範囲 — 部品群、B2B・B2C階層、アイコン、ロゴを充実させ、「あと一つだけ」の独自コントロールやコーポレートサイト専用キットを増やさない。
  • 移行 — 旧ライブラリのパッケージやプロパティとの対応表を用意し、導入を調査頼みにしない。
  • テーマ — モード、製品コンテキスト、部品単位の上書きを扱い、暗いシェル内の明るい決済画面やブランドの見た目を、複雑なProviderの入れ子ではなくプロパティで指定する。
  • 開発者体験 — Web・ネイティブのStorybook、API文書、LLMが読める参照情報で、人もエージェントも実際のButtonを利用できるようにする。
  • リリース — パッケージ間の統一バージョン、CI公開、プレビュービルドにより、各ホストが安全に変更を取り込めるようにする。
  • AIによる導入支援 — 目指すのは、独自の色コードが残る画面ではなく、実製品と同じトークン・部品で組み立てられた試作です。モデルが読める文書、スキル、安定した公開APIで、デスクトップでもモバイルでも別のシステムを生成せずに済むようにします。
コード
一つのシステムを保つ

  一度だけ実装                  製品ごとに複製
  ソース・トークン・基本要素      同じに見えるローカルUI
  Webとネイティブへビルド         共有されていないコード
       ↓                           ↓
  バージョン更新で各アプリへ       デザイン変更のたびに差が広がる

Storybookはアーキテクチャに見えがちですが、実際にはカタログです。コンポーネントが各リポジトリに散在していれば、文書サイトの見た目を良くしても構造は変わりません。アーキテクチャはライブラリにあります。モデル向けを含めた文書は、最初につくった人を待たず、人やエージェントがそれを使えるようにするためのものです。

本物の全社デザインシステムとは何か、と聞かれることがあります。成功の尺度は、自分が全行を書いたかではありません。次のXsollaの画面が、Webでもネイティブでも、製品でもコーポレートサイトでも、人が書いてもエージェントが生成しても、専用Buttonを必要としないこと。そして次のデザイン変更で、各所を書き直さずに済むことです。

複数のデザインシステムを運用するチームに伝えたいこと

主要ライブラリ、製品専用キット、マーケティング用キットがあるなら、新しいFigmaファイルだけでは統一できません。モバイルを第二段階にしても、AIで不足画面を生成しても、分かれた構造は残ります。

不完全でも、共通化の境界をつくることです。同じソースを2回コンパイルし、色、テーマ、角丸、サイズを仕組みとして扱うライブラリに約1か月を使うほうが、似た別システムをもう1年運用するより前進になります。各ホストへのコンパイラー導入が必要で、既存のReact 16環境が対応できなければ、全社向けには使えません。ライブラリをコンパイルし、ホスト側の負担を抑えます。

そのうえで、基本要素と製品の枠組みの境界、ホストが変更できるテーマ、必ず共有する部品、更新が実際に導入されたと確認する方法を明示します。エンジニアにもエージェントにも分かる契約にして、試作が新しいシステムの生成ではなく、既存システムの利用になるようにします。カタログを育てるのは、その後です。

私たちはこうして、複数のシステムからXUIへ進みました。デスクトップにもモバイルにも一つの見た目を届けるため、最初の1か月で共通の契約をつくりました。次の仕事は、AIが最初の画面を描くときも含めて、一つのシステムであり続けることです。