デザインシステムのダークモード対応

ogp

こんにちは。enechainで働いている takurinton です。

これまでのenechainのデザインシステムではdark modeの提供はしておらず、プロダクト側で必要に応じて状態とデザイントークンを保持した上で切り替えてもらうという方式をとっていました。

しかし、事業拡大やプロダクト側からの要望によりdark modeのデザイントークンをデザインシステム側で管理し、chakra-uiのColorModeProviderを用いて切り替える方法を提供することにしました。

今回は、その移行の過程と方法について書きます。

デザイントークンについて

まず移行に関係なく、現状のデザイントークンの管理方法について簡単に説明します。

enechainのデザインシステムではFigmaのstylesとvariablesを使って管理しています。

stylesとvariablesが混在する状態ですが、そこから吐き出されたデザイントークンをビルド時にTypeScriptのコードへと変換する仕組みが既に存在しています。そのため、デザイントークンを更新すればコード側には機械的に反映可能になっています。

デザイントークンのFigma上での管理方法についてはデザイナーの近藤の記事があります。 techblog.enechain.com

移行の手法

具体的な移行の手法について書きます。

移行は、以下の前提がある状態で行いました。

  • 将来的にlight/dark以外の種類のトークンも定義する可能性がある
  • 古いトークンはstylesで、新しいトークンはvariablesで管理している
  • light modeしか使わないプロダクトは特別な手順なく移行可能にしたい

将来的に light/dark 以外の種類のトークンも定義する可能性がある

enechainデザインシステムではchakra-uiを用いてReact用のUIコンポーネントライブラリをプロダクト側に提供しています。chakra-uiにはuseColorModeValueやtheme-tools等のdark modeをサポートする機能があります。

しかし、今回は以下の理由から独自の形でデザイントークンを保持し、dark modeを実現する形を取りました。

  • 十分なデザイントークンが揃っており、light/darkでの整合性も取れている
  • 将来的にdark mode以外のmodeも追加される可能性がある
  • 現状のデザイントークンの形を変えたくない

実装のイメージとしては、themeに対してlightとdarkというkeyを持たせ、それぞれのトークンを持たせることで、colorModeによってトークンを出し分けることができるようにしました。

type Theme = {
  light: {
    colors: {
        // ...
    },
    semanticTokens: {
        // ...
    }
    // ...
  },
  dark: {
    colors: {
        // ...
    },
    semanticTokens: {
        // ...
    }
    // ...
  },

  // light, dark以外のmodeが入る余地を残す
};

古いトークンはstylesで、新しいトークンはvariablesで管理している

dark mode対応に伴い、デザイントークンはvariablesで管理することになりました。
そのため、variablesで作られたデザイントークンも新たに作成、用意されましたが、stylesで管理されていた古いデザイントークンも引き続き使われる可能性があるため、移行の際には新旧トークンを併用する必要がありました。

ここは追々改修していく前提で、新旧トークンをマージして、旧トークンで参照されているが新トークンでは定義されていないところは旧トークンの値を差し込む形で移行コストを減らす方法を取りました。

この部分はあくまで一時的な実装で、後から剥がしてトークンの整理をする予定です。

併用中のトークンのイメージは以下のようになります。

// 旧トークン
{
  "primary": "#fff",
  "sub": "#ddd"
}

// 新トークン
{
  "light": {
    "primary": "#fff",
  },
  "dark": {
    "primary": "#000"
  }
}

// この場合、実際のトークンはこのようになる
{
  "light": {
    "primary": "#fff",
    "sub": "#ddd",
  },
  "dark": {
    "primary": "#000",
    "sub": "#ddd"
  }
}

light モードしか使わないプロダクトは特別な手順なく移行可能にしたい

enechainには10を超えるプロダクトが存在しており、プロダクトによってはlight modeのみの運用をする場合もあります。

そのため、そのようなプロダクトでの移行のコストは最小限にするべく、dark modeが不要なプロダクトでは特別な改修が不要になるように進めました。

具体的には、light modeのトークンとdark modeのトークンを完全に揃え、同じkeyで参照できる状態にすることでこれを実現しました。このためにはデザイナーとの連携が必要になるため、デザイナーと自分でデザイントークンを調整しながら進めました。

呼び出す側は以下のようなコードを呼んでおけば、あとはColorModeProviderで渡すcolorModeの値に合致するトークンを呼び出してくれるような仕組みを作成しました。colorModeのデフォルト値をlightにしておくことで特別な対応は不要になります。

export const Component = fowardRef(function Component() {
  const theme = useTheme();

  // ColorModeProviderで渡しているcolorModeの値によってprimaryの色が自動で変わる
  return <ChakraUiComponent color={theme.semanticTokens.primary} />;
});

デザインシステムでの移行

実際に行なった移行では、以下の手順で進めました。

  • デザイントークンの生成ロジックを修正
  • インターフェイスの変更と型の修正
  • もらったcolorModeによってトークンを出し分け

デザイントークンの生成ロジックを修正

まずはデザイントークンの生成の部分を修正しました。 上で述べた通り、ビルド時にjsonのトークンをTypeScriptに変換するためのフローがあるのですが、既存のデザイントークンにlight/darkのそれぞれの階層を作成し、トークンを対応させました。

インターフェイスの変更と型の修正

lightとdarkの階層を作成し、それぞれのデザイントークンを持たせるために、インターフェイスの修正をしました。
Themeの形式はTokensとColorsをマージしたものになります。Colorsにはlight/darkのそれぞれのセマンティックからーとプリミティブカラーが格納されており、このlightとdarkをkeyにして最終的にはデザイントークンを出し分けることになります。

// 新旧トークンの整合性を取るための型
type DeepMerge<T, U> = {
  [K in keyof T | keyof U]: K extends keyof T
    ? K extends keyof U
      ? T[K] extends object
        ? U[K] extends object
          ? DeepMerge<T[K], U[K]>
          : U[K]
        : U[K]
      : T[K]
    : K extends keyof T
    ? T[K]
    : never;
};

type Colors = {
  light: {
    colors: DeepMerge<typeof PrimitiveColors, typeof LightModePrimitiveColors>;
    semanticTokens: {
      colors: DeepMerge<typeof SemanticColors, typeof LightModeSemanticColors>;
    };
  };
  dark: {
    colors: DeepMerge<typeof PrimitiveColors, typeof DarkModePrimitiveColors>;
    semanticTokens: {
      colors: DeepMerge<typeof SemanticColors, typeof DarkModeSemanticColors>;
    };
  };
};

// light or darkとTokensを合わせたトークンが実際に参照可能となる
type Theme = (Colors["light"] | Colors["dark"]) &
  Tokens &
  Omit<
    ChakraTheme,
    | "colors"
    | "semanticTokens"
    | "fonts"
    | "fontSizes"
    | "styles"
    | "radii"
    | "textStyles"
    | "space"
  >;

もらった colorMode によってトークンを出し分け

まずは先ほど生成したトークンを使用してデザインシステム側で保持するデザイントークンを作成します。

deepMerge関数の戻り値はDeepMerge型になっているので、上で定義したTokens型とColors型のそれぞれに相違があると型エラーを吐いてくれます。

type Themes = Tokens & Colors;

export const themes: Themes = {
  ...chakraTheme,
  config: {
    initialColorMode: "light",
    useSystemColorMode: true,
  },
  fonts,
  fontSizes,
  styles,
  shadows: ShadowsToken,
  radii: { ...BorderRadius, ...NewRadiiToken },
  textStyles: deepMerge(NewTypographyToken, Typography),
  space: { ...Spacing, ...NewSpaceToken },

  // 元々colorsとsemanticTokensがこの位置にいたが、light/darkのそれぞれのトークンを持つように変更
  light: {
    colors: deepMerge(PrimitiveColors, LightModePrimitiveColors),
    semanticTokens: {
      colors: deepMerge(SemanticColors, LightModeSemanticColors),
    },
  },
  dark: {
    colors: deepMerge(PrimitiveColors, DarkModePrimitiveColors),
    semanticTokens: {
      colors: deepMerge(SemanticColors, DarkModeSemanticColors),
    },
  },
} as const;

あとは、useThemeの中でcolorModeを受け取り、デザイントークンを出し分けることでプロダクト側のdark modeの切り替えを実現します。

export type UseThemeReturn = WithCSSVar<Theme>;

export const useTheme = (): UseThemeReturn => {
  // プロダクト側で渡されたcolorModeを取得
  const { colorMode } = useColorMode();
  const theme = useChakraTheme<Themes>();
  // light or darkを取得して差し込む
  const colors = theme[colorMode];

  return {
    ...theme,
    ...colors,
  };
};

実際に使用する側の宣言のイメージは以下のようになります。

ダークモードが不要なプロダクトではColorModeProviderのcolorModeに固定値を入れるか、そもそもColorModeProviderを呼ばなければ動作します。

const root = () => {
  const [colorMode, setColorMode] = React.useState<"light" | "dark">("light");

  return (
    <ChakraProvider>
      <ColorModeProvider value={colorMode}>
        <App onChangeColorMode={setColorMode} />
      </ColorModeProvider>
    </ChakraProvider>
  );
};

プロダクト側での移行

プロダクト側への移行のコストはデザインシステム側としては最小限に抑えましたが、それでもいくつかの対応が必要でした。

その中でもつまづいた点についていくつか書きます。

デザイントークンの参照方法の変更

enechainデザインシステムでは、デザイントークンをTypeScriptのコードとして扱えるようにしたものをexportしています。そのため、useTheme経由でなくてもデザイントークンを参照できます。

import { PrimitiveColors } from "@enechain/design-system";

const Component = () => {
  // こんな感じで直接参照が可能
  return <div style={{ color: PrimitiveColors.red }}>Hello, World!</div>;
};

しかし、これでは固定値のため、dark mode対応しても適切なthemeが適用されません。そのため、useThemeを使ってデザイントークンを参照するように変更する必要があります。

import { useTheme } from "@enechain/design-system";

const Component = () => {
  const theme = useTheme();

  // useTheme経由でthemeを参照しないとdark modeに対応しない
  return <div style={{ color: theme.semanticTokens.primary }}>Hello, World!</div>;
};

この対応をする箇所が想像以上に多くあり、それが10を超えるプロダクトに対して行うのは大変でした。

特定のデザイントークンの漏れ

chakra-uiの仕組みから一部外れたことで、特定のデザイントークンに色が当たらず、デザインが崩れるという問題が発生しました。

今回だと、box-shadowです。box-shadowのトークンはchakra-uiではsemantic tokensとして定義されています。
https://www.chakra-ui.com/docs/theming/shadows#semantic-tokens

元々、enechainデザインシステムではbox-shadowのトークンを保持していませんでした。移行前の段階ではbox-shadowのデザイントークンを明示的に定義しなくても、chakra-uiのsemantic tokensにcolorを渡すことでうまくいっていました。しかし、移行後はlight/darkの階層を作成したため、この方法ではchakra-uiのデフォルトのbox-shadowが適用されてしまいました。

ここは先に洗い出してテスト等を書いておけばなんとかなったはずなので、もう少し事前に対策を練っておくべきでした。

終わりに

enechainデザインシステムでのdark mode移行について書きました。

技術的な難易度はそれほど高くなかったものの、デザイン上の調整やプロダクト側へのアップデート依頼、対応やアップデートにかかるコストの面で負荷があったように感じています。 また、自分個人としてはもう少しうまく、既存の仕組みやchakra-uiの機能を活かしてやれたんじゃないかなという反省はあります。

今後もより社内で使っていただけるような仕組みやライブラリとしての振る舞いを磨いていくべく頑張ります。

enechainでは一緒に働く人間を募集しています。 herp.careers