Read OSS

コンポーネントカスタマイズの5つのレイヤー

上級

前提知識

  • 第1〜5回の記事
  • CSS の詳細度とカスケードのルール
  • CSS @layer の概念

コンポーネントカスタマイズの5つのレイヤー

これまでの5回の記事で、アーキテクチャ、styled パイプライン、コンポーネントの構造、テーマシステム、そしてビルドツールチェーンを順に見てきました。今回はその集大成として、カスタマイズ手段の全体像を整理します。詳細度の順に並ぶ5つの独立したメカニズムが、どのように組み合わさって予測可能で衝突のないカスケードを形成するのかを見ていきましょう。

Material UI は「カスタマイズしにくい」と批判されることもあります。しかし実態はむしろ逆です。カスタマイズの手段が多すぎることこそ課題であり、どの場面でどれを使うべきかを理解することが鍵になります。この記事では、各レイヤーが第2回で解説した styled パイプラインとどう関わるかを明確にすることで、その混乱を解消します。

レイヤー 1: theme による defaultProps

最も軽量なカスタマイズです。CSS を一切書かずに、コンポーネントの prop のデフォルト値を変更できます。

const theme = createTheme({
  components: {
    MuiButton: {
      defaultProps: {
        variant: 'contained',
        disableElevation: true,
      },
    },
  },
});

第3回で確認したとおり、これらのデフォルト値は DefaultPropsProvider コンテキストを通じて useDefaultProps に流れ込みます。解決処理は getThemeProps の中で行われます。

function getThemeProps(params) {
  const { theme, name, props } = params;
  const config = theme.components[name];
  if (config.defaultProps) {
    return resolveProps(config.defaultProps, props);
  }
  return props;
}

resolveProps ユーティリティは、明示的に渡された prop が常にデフォルト値より優先されることを保証します。undefined の prop だけがデフォルト値にフォールバックします。

flowchart LR
    ThemeDefaults["theme.components.MuiButton.defaultProps<br/>{variant: 'contained'}"]
    UserProps["<Button color='primary' />"]
    Resolved["Final: {variant: 'contained', color: 'primary'}"]

    ThemeDefaults --> Resolved
    UserProps --> Resolved

ヒント: defaultProps が適しているのは、アプリ全体でコンポーネントの振る舞いを変えたいとき — デフォルトの variant、size、color などです。特定の variant やステートの組み合わせで見た目を変えたい場合は、レイヤー2またはレイヤー3を使いましょう。

レイヤー 2: theme variants — 条件付きスタイルオブジェクト

theme variants を使うと、まったく新しい prop とスタイルの組み合わせを追加したり、既存のものを上書きしたりできます。{props, style} オブジェクトの配列として定義します。

const theme = createTheme({
  components: {
    MuiButton: {
      variants: [
        {
          props: { variant: 'dashed' },
          style: { border: '2px dashed currentColor' },
        },
      ],
    },
  },
});

第2回で解説した styled パイプラインでは、これらの variants は 249行目 の2番目の tail 式として注入されます。

if (componentName && !skipVariantsResolver) {
  expressionsTail.push(function styleThemeVariants(props) {
    const themeVariants = theme?.components?.[componentName]?.variants;
    if (!themeVariants) {
      return null;
    }
    return processStyleVariants(
      props, themeVariants, [],
      props.theme.modularCssLayers ? 'theme' : undefined,
    );
  });
}

レイヤー名 'theme' に注目してください。CSS layers が有効な場合、theme variants は @layer theme ブロックに配置されるため、@layer components にあるベースコンポーネントのスタイルより高い詳細度を持ちます。

レイヤー 3: theme styleOverrides — スロット単位の CSS

styleOverrides を使うと、サブコンポーネントの特定のスロットを任意の CSS でターゲットにできます。

const theme = createTheme({
  components: {
    MuiButton: {
      styleOverrides: {
        root: { textTransform: 'none' },
        startIcon: { marginRight: 4 },
      },
    },
  },
});

225行目 のオーバーライド注入処理では、各スロットを processStyle で処理し、解決されたオーバーライドをコンポーネントの overridesResolver に渡します。

expressionsTail.push(function styleThemeOverrides(props) {
  const styleOverrides = theme.components?.[componentName]?.styleOverrides;
  if (!styleOverrides) return null;

  const resolvedStyleOverrides = {};
  for (const slotKey in styleOverrides) {
    resolvedStyleOverrides[slotKey] = processStyle(
      props, styleOverrides[slotKey],
      props.theme.modularCssLayers ? 'theme' : undefined,
    );
  }

  return overridesResolver(props, resolvedStyleOverrides);
});

Button の overridesResolver(第3回参照)は、適用すべきオーバーライドキー — styles.rootstyles[ownerState.variant] など — を選択し、配列として返します。

styleOverrides は variants より強力です。variants は指定した props にマッチするコンポーネント全体しかスタイリングできませんが、overrides なら root、startIcon、endIcon、loading インジケーターを個別かつ独立してスタイリングできます。

flowchart TD
    Overrides["theme.components.MuiButton.styleOverrides"]
    Root["root: { textTransform: 'none' }"]
    Start["startIcon: { marginRight: 4 }"]
    End["endIcon: { color: 'red' }"]

    Overrides --> Root
    Overrides --> Start
    Overrides --> End

    Root -->|"overridesResolver maps to"| ButtonRoot["ButtonRoot sub-component"]
    Start -->|"overridesResolver maps to"| ButtonStartIcon["ButtonStartIcon sub-component"]
    End -->|"overridesResolver maps to"| ButtonEndIcon["ButtonEndIcon sub-component"]

レイヤー 4: 派生コンポーネントのための styled() API

theme レベルのカスタマイズでは足りないとき、MUI コンポーネントを styled() でラップして新しいコンポーネントを作ります。

import { styled } from '@mui/material/styles';
import Button from '@mui/material/Button';

const GradientButton = styled(Button)({
  background: 'linear-gradient(45deg, #FE6B8B, #FF8E53)',
  color: 'white',
});

すでに styled されたコンポーネントをさらに styled() でラップする場合、第2回で解説したシステムが重複を防ぎます。muiStyledResolver がまず行うのは、親コンポーネントから sx ハンドラーを除外することです。

mutateStyles(tag, (styles) =>
  styles.filter((style) => style !== styleFunctionSx)
);

これにより、sx の式が親のパイプラインとラッパーのパイプラインで二重に適用されるのを防ぎます。ラッパーは独自の head / body / tail 式セットを持ちます。

指定したスタイルは新しいコンポーネントのパイプラインの body ゾーンに配置されます。つまり、theme のアタッチ後、かつ theme overrides・theme variants・sx の適用前に処理されます。既存の3つの theme カスタマイズレイヤーは、その上からさらに適用されます。

レイヤー 5: sx prop — 最高詳細度のエスケープハッチ

sx prop はパイプラインの最後に処理される、究極のオーバーライド手段です。styleFunctionSx によって実装されています。

function styleFunctionSx(props) {
  if (!props.sx) return null;
  const { sx, theme = EMPTY_THEME, nested } = props;
  const config = theme.unstable_sxConfig ?? defaultSxConfig;

  function process(sxInput) {
    let sxObject = typeof sxInput === 'function' ? sxInput(theme) : sxInput;
    // ...resolve breakpoints, theme-aware shorthands
  }

  return Array.isArray(sx) ? sx.map(process) : process(sx);
}

sx prop は3つの入力形式をサポートします。

  • オブジェクト: sx={{ mt: 2, color: 'primary.main' }} — theme を意識した省略プロパティ
  • 関数: sx={(theme) => ({ mt: theme.spacing(2) })} — theme への完全アクセス
  • 配列: sx={[{ mt: 2 }, isActive && { color: 'red' }]} — 条件付きの組み合わせ

CSS layers が有効な場合、sx のスタイルは 71行目@layer sx にラップされます。

if (!nested && theme.modularCssLayers) {
  return { '@layer sx': sortContainerQueries(theme, removeUnusedBreakpoints(breakpoints, css)) };
}

これにより、sx は MUI のカスタマイズレイヤーの中で常に最高の CSS 詳細度を持つことが保証されます。

バリアント効率化のための CSS カスタムプロパティ

Button のスタイルには、巧みな最適化が施されています。variant × color のすべての組み合わせ(text-primary、text-secondary、contained-primary、contained-secondary など)に対して個別にスタイルを定義する代わりに、CSS カスタムプロパティを間接レイヤーとして活用しています。

Button.js の 118〜194行目 より:

// Variant styles reference custom properties
{ props: { variant: 'contained' },
  style: {
    color: `var(--variant-containedColor)`,
    backgroundColor: `var(--variant-containedBg)`,
  },
},

// Color styles set those custom properties
...Object.entries(theme.palette)
  .filter(createSimplePaletteValueFilter())
  .map(([color]) => ({
    props: { color },
    style: {
      '--variant-textColor': (theme.vars || theme).palette[color].main,
      '--variant-containedColor': (theme.vars || theme).palette[color].contrastText,
      '--variant-containedBg': (theme.vars || theme).palette[color].main,
    },
  })),
flowchart TD
    Variant["variant: 'contained'<br/>color: var(--variant-containedColor)<br/>bg: var(--variant-containedBg)"]
    ColorPrimary["color: 'primary'<br/>--variant-containedColor: #fff<br/>--variant-containedBg: #1976d2"]
    ColorSecondary["color: 'secondary'<br/>--variant-containedColor: #fff<br/>--variant-containedBg: #9c27b0"]

    ColorPrimary -->|"CSS custom props"| Variant
    ColorSecondary -->|"CSS custom props"| Variant

このパターンがなければ、3つの variant × 7色 = 21個のスタイルブロックが必要になります。CSS カスタムプロパティを使えば、3つの variant ブロック + 7つのカラーブロック = 合計10ブロックで済みます。組み合わせが増えても乗算的ではなく線形にスケールします。palette にカスタムカラーを追加しても、増えるのはカラーブロックだけで、variant ブロックはそのまま変わりません。

ヒント: カスタムコンポーネントを作るときも、2つの prop が掛け合わさる場面では CSS カスタムプロパティによる間接参照パターンを採用しましょう。variant のレイアウトを var(--your-prop) で定義し、それらのプロパティを色やサイズごとに設定するようにすれば、CSS の出力量を大幅に削減できます。

詳細度管理のための CSS @layer 統合

theme.modularCssLayers で CSS layers を有効にすると、各カスタマイズレイヤーが CSS のカスケードレイヤーに対応します。

カスタマイズレイヤー CSS レイヤー 詳細度の順序
ベースコンポーネントスタイル(body 式) @layer components 最低
theme の styleOverrides + variants(tail 式) @layer theme 中間
sx prop @layer sx 最高

レイヤーの割り当てはパイプライン内の複数の箇所で行われます。195行目transformStyle では、body 式が props.theme.modularCssLayers を確認します。

return function styleFunctionProcessor(props) {
  return processStyle(
    props, style,
    props.theme.modularCssLayers ? layerName : undefined
  );
};

コンポーネントのレイヤー名は、149行目name オプションで決まります。

const layerName =
  (componentName && componentName.startsWith('Mui')) || !!componentSlot
    ? 'components'
    : 'custom';

MUI 自身のコンポーネントには @layer components が、ユーザーが作成した styled コンポーネント(Mui* という名前でないもの)には @layer custom が割り当てられます。

flowchart BT
    Components["@layer components<br/>MUI base styles"]
    Custom["@layer custom<br/>User styled() components"]
    ThemeLayer["@layer theme<br/>styleOverrides + variants"]
    SxLayer["@layer sx<br/>sx prop styles"]

    Components --> Custom
    Custom --> ThemeLayer
    ThemeLayer --> SxLayer

    style SxLayer fill:#b71c1c,color:#fff
    style ThemeLayer fill:#e65100,color:#fff
    style Custom fill:#1565c0,color:#fff
    style Components fill:#283593,color:#fff

これは CSS-in-JS が長年抱えてきた痛点 — スタイルの注入順序の問題 — を解消します。layers なしでは、最初にレンダリングされた styled コンポーネントのスタイルが最初に注入され、同一の詳細度を持つ後続のスタイルが意図せず上書きしてしまうことがありました。@layer を使えば、カスケードの順序はレイヤーの宣言によって決まり、注入の順序には左右されません。SSR のハイドレーションミスマッチ、コード分割による読み込み順の変動、動的インポートのタイミングも、もはや詳細度に影響しません。

5つのレイヤーをすべて組み合わせる

完全にカスタマイズされた Button が5つのレイヤーをどう通過するか、順を追って見てみましょう。

// Layer 1: Default props
const theme = createTheme({
  components: {
    MuiButton: {
      defaultProps: { variant: 'contained' },           // Layer 1
      variants: [{ props: { size: 'xl' }, style: {} }], // Layer 2
      styleOverrides: { root: { borderRadius: 8 } },    // Layer 3
    },
  },
});

// Layer 4: Derivative component
const MyButton = styled(Button)({ fontWeight: 700 });

// Layer 5: Instance override
<MyButton sx={{ mt: 2 }}>Click me</MyButton>

この Button の最終的な CSS には5つのレイヤーすべてからのスタイルが含まれており、詳細度の順に入れ子になっています。

  1. variant: 'contained' のデフォルト prop により、ベースコンポーネント定義から contained variant のスタイルが選択される
  2. theme.components.MuiButton.variants からマッチする theme variants が追加される
  3. styleOverrides.rootborderRadius: 8 が適用される
  4. styled() ラッパーの fontWeight: 700 が適用される
  5. sx={{ mt: 2 }}marginTop: 16px が最後に、最高詳細度で適用される

この5レイヤーモデルにより、グローバルなデフォルト設定から単一インスタンスのオーバーライドまで、あらゆる抽象度でカスタマイズが可能になります。しかもカスケードは常に予測可能で決定論的です。

シリーズのまとめ

6回にわたる連載で Material UI の内部構造を順に見てきました。pnpm ワークスペースの構成に始まり、styled-engine の抽象化と createStyled の式パイプラインを解説しました。さらに Button を例にしたコンポーネントの構造、CSS 変数を用いたデュアルパステーマシステム、ビルドインフラ、そしてカスタマイズ手段の全体像まで扱いました。

根底にある設計哲学は一貫しています。すべてのレイヤーはアダプターであり、すべてのアダプターは交換可能ということです。styled engine は交換できます(Emotion ↔ styled-components)。theme のモードは切り替えられます(クラシック ↔ CSS 変数)。コンポーネントの theme はスコープを限定できます(Material ↔ THEME_ID による Joy)。詳細度のモデルも選べます(注入順序 ↔ CSS layers)。

このレイヤード・アダプターベースのアーキテクチャが、Material UI を単純なダッシュボードからカスタムテーマ・variant 拡張・インスタンス単位のオーバーライドを持つ複雑なデザインシステムまで、驚くほど多様な要件に対応できる理由です。それもすべて、同じコードベースから。