零弐壱蜂

[CSS in JS] スタイル上書き可能なコンポーネント設計

背景

UIライブラリや大規模プロジェクトでコンポーネントを作成する際、利用するユーザーはスタイルのカスタマイズを行う想定を持たなければならない。もし、コンポーネント側で上書き用のpropsやスタイルAPIを提供していなければ、利用者は詳細度の競合などの問題に直面する。また、CSS in JSの設計によっては、このスタイルの上書きはより難しくなる。

課題

スタイル上書きの困難性

スタイル定義とコンポーネントが密結合していると、利用者のスタイル上書きが難しい。

import styled from '@emotion/styled';

const StyledButton = styled.button`
  background-color: blue;
`;

export function Button({ className, ...props }) {
  return (
    <StyledButton className={className} {...props}>
      Click me
    </StyledButton>
  );
}

利用者がstyled(Button)でスタイルを上書きしようとしても、詳細度の問題で失敗する可能性がある。そもそもコンポーネントがclassNameを受け渡していなければ、styled()によるラップ自体が機能しない。また、ライブラリ側で&&&!importantを使っていると、詳細度の競合が発生する。

// 利用者側のコード
import { Button } from 'ui-library';
import styled from '@emotion/styled';

const CustomButton = styled(Button)`
  background-color: red; /* ライブラリ側で &&& や !important を使うと反映されない */
`;

このアプローチの問題点は次の通り。

  1. 利用者がライブラリの内部実装(詳細度)に依存しカプセル化が破壊される
  2. ライブラリのバージョンアップで詳細度が変わると利用者側のコードが壊れてしまう

これらは利用者側で対処療法を強いられてしまうため、ライブラリの設計として不適切だ。

ちなみにEmotionやStyled Componentsで詳細度を上げる方法として&&&(トリプルアンパサンド)があるが、ライブラリ側で詳細度がどれぐらいなのかはドキュメントなどを用意しなければ、その情報はDevToolsで確認しない限り不透明である。

const CustomButton = styled(Button)`
  &&& {
    background-color: red;
  }
`;

解決方法

UIライブラリや大規模プロジェクトでは、スタイル定義とコンポーネントを分離してエクスポートするのを推奨する。

利用者は当該コンポーネントのスタイルを上書きでき、詳細度の競合も回避できる。テーマ拡張やGlobalスタイルとの併用も可能だが、本記事では最もシンプルな分離パターンを紹介する。

UIライブラリ側の実装

import { css } from '@emotion/react';
import type { ComponentProps } from 'react';

type ButtonProps = ComponentProps<'button'>;

// スタイルを分離してエクスポート
export const buttonStyle = css`
  background-color: blue;
  padding: 8px 16px;
  border-radius: 4px;
`;

export function Button({ children, ...props }: ButtonProps) {
  return (
    <button css={buttonStyle} {...props}>
      {children}
    </button>
  );
}

この実装は小規模プロジェクト向けである。中規模以上であれば、スタイルを別ファイルに分離しつつ、ディレクトリ単位でコロケーションを維持して、コンポーネントとスタイルの関連性を保つのが良い。

components/
  Button/
    index.tsx        # コンポーネント
    styles.ts        # スタイル定義
// styles.ts
import { css } from '@emotion/react';

export const buttonStyle = css`
  background-color: blue;
  padding: 8px 16px;
  border-radius: 4px;
`;
// index.tsx
import type { ComponentProps } from 'react';
import { buttonStyle } from './styles';

export { buttonStyle };

type ButtonProps = ComponentProps<'button'>;

export function Button({ children, ...props }: ButtonProps) {
  return (
    <button css={buttonStyle} {...props}>
      {children}
    </button>
  );
}

利用者側のコード

小規模・中規模どちらのファイル構成でも使い方は同じ。

// 利用者側のコード
import { buttonStyle } from 'ui-library';
import { css } from '@emotion/react';

// スタイルを上書き
const customButtonStyle = css`
  ${buttonStyle}
  background-color: red;
`;

function CustomButton() {
  return <button css={customButtonStyle}>Custom Button</button>;
}

// 配列で合成する方法も可能(後の要素が優先)
function AnotherCustomButton() {
  const overrideStyle = css`
    background-color: red;
  `;

  return <button css={[buttonStyle, overrideStyle]}>Custom Button</button>;
}

なお、@emotion/reactcss propでは、className経由のスタイルがcss propのスタイルを上書きする仕様である。

トレードオフ

メリット

  • スタイルの上書きが容易
  • 詳細度の競合を回避
  • 必要な部分のみ上書き可能

デメリット

  • コンポーネント単体でスタイルが完結しなくなる(ディレクトリ単位のコロケーションで関連性は保持可能)
  • スタイルの粒度設計が必要

参考