blog.yuzu441.com

デザインシステムの作成に向けてライブラリ選定2026を考える

tags: javascript typescript

仕事で共通UIを提供することになりそうなので、ライブラリ選定2026を考えた。

前提としてコンポーネントはどんなライブラリに変更しても問題ないようにweb componentsを利用するという案もあったがReactを前提に考えるという事になった。 ちなみに shopifyのpolaris はreactからweb components実装に移行しているらしい。

ライブラリ選定

選択したライブラリとその理由。vite+ 1 も考えたが一旦vite+周りで使っているライブラリを知らないので直に入れて素振りをしている 2 。 最終的にvite+乗り換えも視野ではある。

  • react
  • tsdown
    • tsup の代替として移行先としても示されていたので選定
  • oxlint, oxfmt
    • eslint互換なのでpluginがbiomeに比べて作りやすいという印象
    • デザインシステムという都合上、複数プロジェクトからprが飛んでくる事が想定されるのでルールの実装も自分たちで行えるのが良いという判断
  • vanilla-extract
    • uiをtype-safeで作成するために選定
    • zero runtimeなのでパフォーマンスで問題になりづらく、nextjsのapp routerなどでも動く
    • css moduleも検討したが型安全性とテーマの型定義を考えるとvanilla-extractが無難という判断
    • panda cssは書き方が独特な印象で見送り
    • rolldown用のpluginがないのでrollupのプラグインを利用する

test周り

プロジェクト構成

ai時代を考えると参照できる範囲にコードがあることが望ましいのでmonorepoを使う。workspaceの管理は困るまでは pnpm workspace を利用する

├── packages
│   ├── design-tokens
│   └── ui

design-tokens

デザインシステムのトークンを定義するプロジェクト。

style-dictionary を利用してprimitive tokenを外部に提供する。

ui

reactコンポーネントを提供するプロジェクト。

ここでdesign-tokensで作ったprimitive tokenをsemantic tokenに置き換えてdefault themeとしても提供する。色等を変えたくなった時に外部からthemeを設定して渡す事で色味を変更できるように設計する。

実装イメージ

design-tokensの実装

以下のようなjsonを定義して、css variablesとjsコードとして出力する。

{
  "color": {
    "blue": {
      "500": {
        "$value": "#3b82f6",
        "$type": "color"
      }
    },
    "red": {
      "500": {
        "$value": "#ef4444",
        "$type": "color"
      }
    },
    "green": {
      "500": {
        "$value": "#22c55e",
        "$type": "color"
      }
    }
  }
}
:root {
  --color-blue-500: #3b82f6;
  --color-red-500: #ef4444;
  --color-green-500: #22c55e;
}
export const ColorBlue500 = '#3b82f6'
export const ColorRed500 = '#ef4444'
export const ColorGreen500 = '#22c55e'

uiの実装

design-tokensで定義した値を使ってsemantic tokenを定義し、uiを作る。

vanilla-extractの createThemeContract を利用してテーマの形を定義することでテーマを型安全に作れる。

import { createThemeContract, createTheme } from '@vanilla-extract/css'
import { ColorBlue500, ColorRed500, ColorGreen500 } from '@xxx/design-tokens'

export const vars = createThemeContract({
  color: {
    primary: null,
    background: null,
    foreground: null,
  },
})

export const blueTheme = createTheme(vars, {
  color: {
    primary: ColorBlue500,
    background: '#ffffff',
    foreground: '#1a1a1a',
  },
})

count.css.ts でのstyle定義

import { style } from '@vanilla-extract/css'
import { vars } from './theme.css.js'

export const container = style({
  padding: 10,
  color: vars.color.primary,
})

count.tsx のコンポーネント定義。ここではスタイルの有効確認だけでなくreactが動作していることを確認するためカウントアップする機能を作成。

import { useState, type JSX } from "react";
import { container } from "./count.css.js";

export function Count(): JSX.Element {
  const [count, setCount] = useState(0);

  const incr = () => setCount((c) => c + 1);

  return (
    <div className={container} onClick={incr}>
      <p>Hello world {count}</p>
    </div>
  );
}

tsdown.config.tsでは以下のように設定しtsdownでライブラリ形式としてビルドする

import { defineConfig } from 'tsdown'
import { vanillaExtractPlugin } from '@vanilla-extract/rollup-plugin'

export default defineConfig({
  entry: {
    index: './src/index.ts',
    hello: './src/hello.tsx',
    ThemeProvider: './src/ThemeProvider.tsx',
  },
  plugins: [vanillaExtractPlugin()],
})

これをviteなり、nextjsのプロジェクトを作って読み込めば <Count /> で表示できた

工夫点

1. 読み込んだコンポーネントのcssだけ読み込む

entryにindexだけを書くように単一のものにしてしまうと全てのコンポーネントが1つのcssにバンドルされてしまい、利用しない場合でも全てのコンポーネントのcssを読み込む事になってしまう。 そのためtsdownのコンフィグ設定でentryを分割することによって、生成されるcssを分割し利用しているコンポーネントのcssだけを読み込むようにしている

2. import {Count} from '@xxx/ui' のようにコンポーネントをimportできること

案としては from '@xxx/ui/count' のように利用するコンポーネントを明確にimportパスに含めてimportするという案があったが、これだとimport文の行が多くなるのでやりたくなかった

3. テーマの上書き

利用側で独自のテーマを定義して上書きする。以下のような defineTheme 関数を定義して型安全にテーマを上書きできるようにしている

import merge from 'deepmerge'

/** Creates a theme config, optionally merging overrides into a base theme. */
export function defineTheme(config: ThemeConfig): ThemeConfig
export function defineTheme(base: ThemeConfig, overrides: Partial<ThemeConfig>): ThemeConfig
export function defineTheme(configOrBase: ThemeConfig, overrides?: Partial<ThemeConfig>): ThemeConfig {
  if (!overrides) return configOrBase
  return merge(configOrBase, overrides) as ThemeConfig
}

まとめ

基本的に定番ライブラリのrust実装ツールのような新しいものに置き換わっただけになりそうだった。
ただツールは変われどやることは変わらず、実行速度が早くなったのはai時代には試行錯誤やガードレールとしての確認が早くなるのでツール類は早ければ早いほど良い。

一番悩んだのはcss周りで、メンバーの学習コストだけ考えるとcss moduleの方が楽だったがテーマの型定義まで考えるとvanilla-extractにするという判断になった。あとvanilla-extractのrolldown対応がまだなくrollupプラグインで凌いでいる所は今後のリスクかなという気がしている。

最後に今回の構成を考えるにあたって素振りをしたリポジトリがこちら https://github.com/yuzu-sandbox/xxx-ui-template

Footnotes

  1. Vite, Vitest, Oxlint, Oxfmt, Rolldown, tsdown, and Vite Task をまとめたもの

  2. 現状vite-plusを素振りした所最新をインストールしても内部viteがv6を使っている?