🎨

Material Design 3 の Buttons を MUI で実装する

に公開

私は仕事として業務システムを開発しており、MUIのMaterial UIを採用しています。
ユーザーにとってよく見慣れた理解しやすいデザインであることがMaterial Designのメリットだと考えています。また私自身、使い慣れているのでMUIのシステムはとても開発しやすいと感じます。

MUIのMaterial UIの不満の1つが、Buttonのvariantが古いことです。
contained,outlined,textだけでは物足りない感じです。

先日Material 3 Expressiveが発表されましたね。Material Design自身がどんどん進化しているので、理解しやすいメリットは残しつつ新しいデザインも採用していきたいものです。

ということで今回は以下の内容を記事にします。

  • Buttonのvariantのカスタマイズ方法
  • Material Designのルールに沿った色の決め方
  • variant以外のpropsに対応させる方法

成果物

https://github.com/michiharu/material-v3-by-mui

https://stackblitz.com/~/github.com/michiharu/material-v3-by-mui

Buttonで使えるvariantを次のように変更しました。

variant contained elevated filled tonal outlined text
Before - - -
After -

こちらは MUI 公式ページのButtonのvariantsです。

そして今回実装したButtonのvartiantsです。

Material Designの公式ページの解説で使用されている色を採用することで正しい方法で実装できているか確認しやすいと考えました。

Buttonのvariantのカスタマイズ方法

公式ドキュメントはこちらです。

https://mui.com/material-ui/customization/theme-components/#variants

Themeへのvariantの追加方法は"Adding styles based on new values"という見出しにサンプルコードが書かれています。

const theme = createTheme({
  components: {
    MuiButton: {
      styleOverrides: {
        root: {
          variants: [
            {
              // `dashed` is an example value, it can be any name.
              props: { variant: 'dashed' },
              style: {
                textTransform: 'none',
                border: `2px dashed ${blue[500]}`,
              },
            },
          ],
        },
      },
    },
  },
});

まず新しいvariantの値をThemeに受け付けてもらえるようにします。

import '@mui/material/Button';

declare module '@mui/material/Button' {
  interface ButtonPropsVariantOverrides {
    elevated: true;
    filled: true;
    tonal: true;
    contained: false; // 使わない
  }
}

次にThemeの作成ですが、サンプルコードはネストが深いですね。
Material 3 では全部で5つのvariantを実装するので、見通しが悪いのは困ります。

とりあえず次のようにしてみました。

const elevated: ButtonVariant = {
  props: { variant: 'elevated' },
  style: { },
};

const filled: ButtonVariant = {
  props: { variant: 'filled' },
  style: { },
};

const tonal: ButtonVariant = {
  props: { variant: 'tonal' },
  style: { },
};

const outlined: ButtonVariant = {
  props: { variant: 'outlined' },
  style: { },
};

const text: ButtonVariant = {
  props: { variant: 'text' },
  style: { },
};

export const theme = createTheme({
  components: {
    MuiButton: {
      styleOverrides: {
        root: {
          variants: [elevated, filled, tonal, outlined, text],
        },
      },
    },
  },
});

ButtonVariantの型定義はcreateThemeからコードジャンプして
node_modules/@mui/styled-engine/esm/index.d.tsの内容を参考にしました。

type Variant<Props> = {
  props:
    | (Props extends {
        ownerState: infer O;
      }
        ? Partial<Omit<Props, 'ownerState'> & O>
        : Partial<Props>)
    | ((
        props: Partial<Props> & {
          ownerState: Partial<Props>;
        }
      ) => boolean);
  style:
    | CSSObject
    | ((
        args: Props extends {
          theme: any;
        }
          ? {
              theme: Props['theme'];
            }
          : any
      ) => CSSObject);
};

type ButtonVariant = Variant<ButtonProps>;

あとはそれぞれのvariantにstyleを記述します。

Material Designのルールに沿って色を作る

https://m3.material.io/styles/color/system/overview

Material Designの公式ページにはいろいろ書いていますね。

本来ならその理論をしっかり理解すべきなのだと思いますが、
Primary Colorを決めたら他の色を作ってくれる便利なNPMライブラリーがあるのでそれを使いました。

https://www.npmjs.com/package/@material/material-color-utilities

import { createTheme } from '@mui/material/styles';

import {
  argbFromHex,
  hexFromArgb,
  themeFromSourceColor,
} from '@material/material-color-utilities';

const colorSchemes = themeFromSourceColor(argbFromHex('#65558F')).schemes;
const primary = hexFromArgb(colorSchemes.light.primary);
const secondary = hexFromArgb(colorSchemes.light.secondary);

上記のコードではprimary,secondaryの色だけ変数に格納しましたが、colorSchemes変数から他にもいろいろな色を取り出すことができます。

variantごとのButtonの背景色や文字色は、以下のリンクを参考にします。

https://m3.material.io/components/buttons/specs

この表にはButtonがToggleとして使用される場合のunselected/selectedの色が含まれていますが、とりあえずDefaultだけコードに反映させました。

const elevated: ButtonVariant = {
  props: { variant: 'elevated' },
  style: {
    backgroundColor: hexFromArgb(colorSchemes.light.surface),
    color: hexFromArgb(colorSchemes.light.primary),
  },
};

const filled: ButtonVariant = {
  props: { variant: 'filled' },
  style: {
    backgroundColor: hexFromArgb(colorSchemes.light.primary),
    color: hexFromArgb(colorSchemes.light.onPrimary),
  },
};

const tonal: ButtonVariant = {
  props: { variant: 'tonal' },
  style: {
    backgroundColor: hexFromArgb(colorSchemes.light.secondaryContainer),
    color: hexFromArgb(colorSchemes.light.onSecondaryContainer),
  },
};

const outlined: ButtonVariant = {
  props: { variant: 'outlined' },
  style: {
    borderColor: hexFromArgb(colorSchemes.light.outlineVariant),
    color: hexFromArgb(colorSchemes.light.onSurfaceVariant),
  },
};

const text: ButtonVariant = {
  props: { variant: 'text' },
  style: {
    color: hexFromArgb(colorSchemes.light.primary)
  },
};

Material Designというデザインシステムを遵守するなら、Toggleのunselected/selected以外にも disabled, hover, focus など様々な状態ごとの色についても考慮する必要がありますが、この記事では割愛させて頂きます。

variant以外のpropsに対応させる方法

ここまでに解説した内容にちょっと手を加えると次のようなボタンが表示できます。

で、ちょっと試しにvariant="elevated"のButtonにsize="large"を設定してみると動かないんです。color="secondary"も効きません。「え?」ってなりました。StackBlitzのリンクを開いて確認してもらうのが早いと思います。

もちろん、outlinedのようにデフォルトで用意されているvariantにsize="large"を設定するのは動きます。

先程も紹介したMUIのドキュメントの"Overriding styles based on existing and new props"を確認しました。次のようにsize="large"の設定を追加して動きました。

const large: ButtonVariant = {
  props: { size: "large" },
  style: {
    fontSize: 15,
    padding: `${base.spacing(1)} ${base.spacing(2.5)}`,
  },
};

export const theme = createTheme({
  components: {
    MuiButton: {
      styleOverrides: {
        root: {
          variants: [elevated, filled, tonal, outlined, text, large],
        },
      },
    },
  },
});

新しいvariantでsize="small"を使いたかったらsmallの定義も追加しましょう。

新しいvariantには、デフォルトのvariantに期待するような振る舞いをしてくれないので、組み合わせて使用したいpropsの振る舞いを1つ1つ定義してあげないといけません。少なくともsize, colorはすべて定義しておいたほうがコンポーネントを書くときのストレスが減りそうです。

まとめ

今回のことを試してみて、改めてMaterial Designのドキュメントを読み込みました。すごく細かなところまで書かれているMaterial Designを、簡単に使えるようにしてくれているMUIチームに感謝です。

またStackBlitzを初めて使ってみましたが、すごく便利ですね。
OSSに感謝。

Discussion