🦄

【MUI】Next.js×MUI×AtomicDesignにおけるオリジナルテーマの適応とコンポーネント設計について

2022/10/20に公開

タイトルがどっかの論文みたいになってしまいましたが、要はこういうことです。

「MUIの機能は継承しつつ、スタイルだけオリジナルに定義して設計はAtomicDesignにしたい!」

というときの個人的なベスト?プラクティスです(もっといい方法があれば教えてください...)。

この実装は需要があるだろうと思いつつ、調べてもあまり記事を見かけなくて困っていました。

なので実現方法と設計・運用ルールを自分なりに考えた結果をまとめてみます。

(Next.js、MUI、Atomic Designなど登場人物の紹介は省きます。
すでにすばらしい記事がたくさんあるのでわからない方はぜひ調べてみてください)

⛰ 目的

  • MUIの機能を引き継ぎつつスタイルのみを上書きする
  • 都度スタイルを上書きするのではなく、オリジナルテーマを定義して運用する
  • デザイン設計はAtomicDesignを採用する

📦 前提

  • Next.js(Typescript)を使用する
  • サイト構成は下記を想定する
    • どんな構成でも採用できますが今回はこの構成を前提にお話します

総合TOPページがあり、各サービスを内包しています。
コンテンツ1〜3のページ構成は各ナンバー間で共通化されており、a, b, cそれぞれのドメインに紐づいた内容が表示されます。

たとえば旅行案内サイト『旅Guide』があったとします(ネーミングひどいのはわざとです、いやほんと、ガチで)。
その配下に『旅Guide 国内』、『旅Guide 海外』、『旅Guide 銀河』などのようにサービスが派生しているイメージです。

旅先一覧ページというコンテンツがあれば、レイアウトは同じで国内、海外、銀河系に応じた情報がそれぞれのページで表示される想定です。

💡 結論① 構成について

結論、コンポーネントは下記の構成がよいかと思っています。

src/components配下はAtomic Designの思想に則り各階層にディレクトリを分けます。

もちろん単位の小さいものから順にimportされてゆき、最終的にTempletes配下のファイルは各Pages配下のファイルで共通的に利用されます。

Templetesにあたるコンポーネントはドメインの知識を持たない骨組みであるため、こうすることでレイアウトは使いまわしつつそれぞれのサービスに応じたコンテンツを表示することができます。

サイトデザインの複雑さにもよりますが、私は各ページに対応したテンプレートファイルが存在していたほうがよいかなと思っています。
/Templetes/TopTemp.tsxというTOPページ用のテンプレートファイルは
/pages/a/index.tsx,/pages/b/index.tsx,/pages/c/index.tsxで共通的に利用されるイメージです。
/entry.tsxがあるならEntryTemp.tsxがあり、各サービスの/entry.tsxで利用されます。

もしサイトの構成がもっともっとシンプルであれば、Templetesでは本当にレイアウトのみを扱い、シンプルなレイアウト用のテンプレート、2カラム用のテンプレート、などで使いまわせばよいのかなと思います。

💡 結論② MUIコンポーネントのstyle上書きとその利用ルール

  • 基本的にAtoms, Moleculesにて名前付きimportする
    • Organisms以降でのimportを禁止するわけではない(詳細は後述)
  • 名前付きimportをしたのち、styledによるスタイルの上書きを行い、exportする
  • プロジェクト内でexportされたコンポーネントを使用し、独自のコンポーネントを組み上げていく

上記が基本的なルールです。

つまるところ、一度プロジェクト内にimportしてからスタイルだけを上書きしてexportし、その上書きされたコンポーネントを使っていろいろ作っていこうねというお話です。

見ていただいたほうが早いと思うのでButtonコンポーネントを例にします。

/components/Button.tsx
// コンポーネントとその型を名前付きimportする
import { Button as MuiButton } from '@mui/material';
import { ButtonProps as MuiButtonProps} from '@mui/material';
// MUIが提供しているstyledをimport
import { styled } from '@mui/system';

// 元の型を継承してpropsを追加
export interface StyledButtonProps extends MuiButtonProps {
  rounded?: 'circle' | 'rounded' | string;
}

// styledに元のコンポーネントを渡し、型はextendsしたものを使う
export const Button = styled(MuiButton)<StyledButtonProps>(
  ({ rounded = 'circle' }) => ({
    ...(rounded == 'circle'
      ? { borderRadius: '100vw' }
      : rounded == 'rounded'
      ? { borderRadius: '5px' }
      : { borderRadius: rounded }),
  }),
);

上記がミニマムな実装です。上から順に見ていきます。

📖 解説 styleを上書きして再定義編

importするもの

/components/atoms/Button.tsx
// コンポーネントとその型を名前付きimportする
import { Button as MuiButton } from '@mui/material';
import { ButtonProps as MuiButtonProps} from '@mui/material';
// MUIが提供しているstyledをimport
import { styled } from '@mui/system';

// 以下省略

MUIからButtonButtonPropsを名前つきimportし、別名を付けます。
hoge as hugaという形を取ることで、importした要素をasの後ろに指定した名前で扱うことができます。

今回はそれぞれMuiという接頭辞をつけましたが、ここはなんでもよいです。
プロジェクト内で統一さえされていればOKなのでわかりやすい名前にしていただければと思います。

名前付きでimportする理由については後述します。

型の上書き

/components/atoms/Button.tsx
// import群省略

// 元の型を継承してpropsを追加
export interface StyledButtonProps extends MuiButtonProps {
  rounded?: 'circle' | 'rounded' | string;
}

// 以下省略

extendsを利用することですでに定義されている型に項目を追加することができます。

先ほど名前付きimportしたMuiButtonPropsを継承し、新たにStyledButtonPropsという型を定義しました。

ここも命名はなんでもよいですが、Styled{コンポーネント名}Propsとするのが慣例だと思います。

スタイルの上書き

/components/atoms/Button.tsx
// import群と型省略

// styledに元のコンポーネントを渡し、型はextendsしたものを使う
export const Button = styled(MuiButton)<StyledButtonProps>(
  ({ rounded = 'circle' }) => ({
    ...(rounded == 'circle'
      ? { borderRadius: '100vw' }
      : rounded == 'rounded'
      ? { borderRadius: '5px' }
      : { borderRadius: rounded }),
  }),
);

ここではstyled()に先ほどimportしたMuiButtonを渡しています。
そして型も先ほど定義したStyledButtonPropsを指定します。

こうすることで「MuiButtonのスタイルをStyledButtonProps型を用いて上書き」することができます。

先ほど型に追加したrounded({ rounded = 'circle' })と書き、デフォルト値を設定しつつPropsに渡します。

処理の中にはCSSを書くことができ、ここでPropsによる分岐が行えます。

もしroundedの値がcircleならborderRadiusの値が100vwになります。
roundedの値がcircleでなくroundedならborderRadiusの値が5pxになります。
さらに上記の分岐に当てはまらない場合、受け取ったstring型の文字列をborderRadiusの値とします。

ここはstyled-componentsを触っていれば理解しやすいかと思います。
リテラルでCSSを書くこともできますが、個人的にはハイライトされないことと、emotionの利用経験からオブジェクトでCSSを記述することに慣れているため、上記の形がしっくりきます。

ちなみに、名前付きimportしたのはここで定義するコンポーネントをButtonとしてexportするためです。
もし別名をつけないとなると、MUIのButtonと名前が重複してしまい使用することができません。

もちろん上書きするほうをMyButton, CustomButton, YakkunButtonという風にしてもよいのですが、すべてのコンポーネントに接頭辞がつくのはさすがに美しさに欠けます。

ということで、私はMUIのものを名前付きimportすることを推奨します。

実際に使うとき

pages配下にsample.tsxを作成し、先ほどのボタンを呼び出してみました。
importのパスは@/~~となっていますが、絶対パスの設定をしていない場合は相対パスでimportしてください。

このとき注意していただきたいのは『MUIからimportしてはいけない』という点です。
せっかく上書きして定義したものがプロジェクト内に存在しているのに、元のコンポーネントを使ってしまうと意味がないためです。
必ず自分で定義したButtonをimportしましょう。

画面では先ほど定義したroundedがVSCodeの補完候補に挙がっているのがわかります。

それと同時に、元のMUIが提供している型もすべて継承しているため、元から存在するvariantも正常に指定できていることがわかります。
もちろん他のprops含め、MUIがButtonとして提供しているすべての機能を引き継いでいます。

これで「MUIの機能を引き継ぎながらスタイルのみ上書きして再定義」をクリアすることができました。

Atomic的な運用について

補足しておくとデザイン設計はAtomoic Designであるため、たとえば例にあったButton.tsx/components/atomsに定義されるべきです。

そして、Moleculesやその他階層でButtonを利用し、必要なコンポーネントを構成していきます。

これと同じことをTypographyやChipなどのコンポーネントでも行なっていけばMUIの機能を継承しつつスタイルのみ上書きして運用できます。

ただ、ここでひとつ疑問点が発生します。

『実態を持たないコンポーネントの定義をどうするか』ということです。

つまり、ButtonやTypographyはUIとしての見た目を保持していますが、レイアウトを整える役割のみを担うStackやGridは実態を持っておらず、自身のプロジェクトに再定義したところで上書くスタイルがないことがほとんどです。

結論ひとつの正解はなく、これらをまとめてatomsとして再定義するのもよいと思いますし、実態をもたないものはatoms, molecules, organisms, templetesのすべての階層でMUIから直接importして利用するのもアリだと思います。

ただ、実態を持たないコンポーネントの特性として(当然ですが)あらゆる要素を内包するために使います。

そしてAtomic的な思想では自身と同等または自身より小さい階層の要素を内包することができません。

そう考えるとatomsに定義するのは不自然ではないかとなってくるのですが、このあたりは独自ルールも取り入れてチームが運用しやすいように共通認識が取れていればOKだと思っています。

実際に私がフロントの運用フローを考えていた際もいくつか独自ルールを適応して資料を作成しました。

具体的には下記です。

  • 各階層配下にも適宜ディレクトリを切ってよい
  • 自身と同階層のコンポーネントも状況に応じて内包してもよい

ひとつめに関しては単純にatoms配下のファイルが多すぎて見通しが悪くなっていたためです。

atoms/iconsというディレクトリを切ってアイコン群をそこにまとめたりしていました(そもそも各階層配下の運用についてAtomic Designは言及していないような気もしますが)。

ふたつめについては、実際問題これを許容しないと構築できないじゃんとなったためです。

Templeteを除くと階層が3つしかないため、大きく複雑なコンポーネントを作成する際にどうしても階層が足りなくなってしまいました。

解決方法としては、

  • 各階層に不相応な粒度で定義することを許容する
  • 階層の数を独自に増やす
  • 同階層のコンポーネントの内包を許容する

の3つが考えられますが、消去法で最後の選択肢を取りました。

ひとつめはそもそもの再利用性が損なわれてしまうためナシ。

ふたつめは階層名なににしよう問題、各階層のランク付問題が発生するためナシです。

どういうことかというと、atom(原子)→molecule(分子)→organism(生体)から派生して適切なネーミングを行える自信がないということです。

また、これを許容するとどこまでオリジナルのディレクトリを切ってよいのか際限の決定が難しいです。

挙句の果てはatom(原子)→molecule(分子)→organism(生体)→.......→galaxy(銀河)とかになってしまうことを危惧しました。

なので状況に応じて同階層のコンポーネントの内包を許容しました。

そうしないと無理な粒度だな、というときにこの行動を取りますがほぼほぼorganismsで起こります。

そして、今のところ大きな問題は起きていません。

Themeの上書き

「スタイルは上書きできたけど、自社(個人)サイトでのテーマを定義したい!」というときもあります。

そういったときはMUIのcreateThemeを使って行います。

こちらも同様にMUIで定義されてるものを上書きしていくのですが、Typescriptを使用している場合は項目の追加と同時に型も定義してあげる必要があるので少しややこしいです。

このあたり、あまり自信がないので間違いなどあればご指摘いただきたいです...。

src/styles/customTheme.ts
import { createTheme } from '@mui/material/styles';

// declare{}内でMUIの型を拡張することができる
declare module '@mui/material/styles' {

  // オリジナルの型
  interface Gradation {
    primary: string;
    secondary: string;
    tertiary: string;
  }
  
  // 型Paletteの上書き
  interface Palette {
    // 新規でkeyを追加すると同時に独自で定義した型も使うとき
    gradation: Gradation;
    // 既存の型を利用するとき
    alert: PaletteOptions['primary'];
    gray: SimplePaletteColorOptions;
  }

  // 型PaletteOptionsではpalette:{}直下の項目名を拡張できる
  interface PaletteOptions {
    gradation: Gradation;
    alert: PaletteOptions['primary'];
    gray: SimplePaletteColorOptions;
  }
  
  // 型SimplePaletteColorOptionsではさらに一階層下の項目名を拡張できる
  interface SimplePaletteColorOptions {
    primary?: string;
    secondary?: string;
    tertiary?: string;
    sub?: string;
    light?: string;
    pale?: string;
    start?: string;
    end?: string;
  }
}

// 定義、拡張した型を以ってテーマを上書きしていく
export const customTheme = createTheme({

  // paletteの中身を拡張していく
  palette: {
    primary: {
      main: "#hoge",
      sub: "#huga",
      light: "#piyo,
      pale: "#hogehoge",
      dark: "#hugahuga",
    },
    // 追加した項目gradationは自身で定義した型Gradationを参照している
    gradation: {
      primary: "liner-gradient(XXXdeg, #hoge, #huga)",
      secondary: 'linear-gradient(YYYdeg, #piyo, #hogehoge)',
      tertiary: 'linear-gradient(ZZZdeg, #hugahuga, #hoge)',
    }
    .
    .
    .
    // 省略
  },
})

使うとき

src/pages/_app.tsx
import { customTheme } from "@/styles/customTheme";
import { ThemeProvider } from "@emotion/react";
import { AppProps } from "next/app";

function MyApp({ Component, pageProps }: AppProps) {

  return (
      <ThemeProvider theme={customTheme}>
        <Component {...pageProps} />
      </ThemeProvider>
  );
}
export default MyApp;

まず_app.tsxThemeProviderを使い、定義したcustomThemeを渡します。

このときemotionThemeProviderを使わないとうまくいかなかったのですが理由がわかりません...

どなたか詳しい方がおられたら教えていただきたいです。

components/atoms/Button.tsx
import { Button as MuiButton } from '@mui/material';
import { ButtonProps as MuiButtonProps } from '@mui/material';
import { styled } from '@mui/system';

export interface StyledButtonProps extends MuiButtonProps {
  bgColor?: 'primary' | 'secondary' | string;
}

export const Button = styled(MuiButton)<StyledButtonProps>(
  ({ bgColor, theme }) => ({
    ...(bgColor == 'primary'
      ? { background: theme.palette.gradaion.primary }
      : bgColor == 'secondary'
      ? { background: theme.palette.secondary.main }
      : { background: bgColor }),
  }),
);

いったんテーマカラーだけに絞ってまたButtonで作成してみました。
先ほどはroundedを追加していましたが、今回はわかりやすくbgColorのみにしています。

styledを使ってスタイルを再定義する際、引数としてbgColor,そしてthemeを渡すようにします。

あとはそれを使ってCSSを切り替えていくだけです(当然切り替えなくても使えます)。

運用ルールとして、themeの参照はこのように/components配下でstyledを使用するときのみ行います。

つまり、Templete階層のコンポーネントを作成中にそこで直接customTheme.tsをimportし、sx={}の中で参照することを許容しません。

理由は、

  • 各コンポーネントは使用時、自動的にthemeを参照している状態である
  • 各コンポーネントは使用時、自動的にグローバルな情報が与えられている
  • 各コンポーネントは使用時、propsの変更により情報を出しわけすることができる

という状態であるべきと考えているからです。

このあたりのUI設計はすべて未来で利用するときのための投資なのです。

その他グローバルなもの

テーマといえば色ですが、ブレイクポイントや文字に関しても同様にグローバルに扱いたい要素だと思うのでサンプルを載せておきます。

先ほどと同じファイルで型を拡張し、同じくcreateThemeでpaletteの後ろに拡張したい項目をせっせと書いていくだけです。

src/styles/customTheme.ts
declare module '@mui/material/styles' {
  // 色の型定義など省略

  // breakpointの使用項目を設定
  interface BreakpointOverrides {
    sm: true;
    md: true;
    lg: true;
    xl: false;
  }
}

export const customTheme = createTheme({
  palette: {
    // 色の拡張など省略
  },
  // 何pxで切り替わるかの設定
  breakpoints: {
    values: {
      xs: 0,
      sm: 376,
      md: 768,
      lg: 1040,
    },
  },
})

個人的にはブレイクポイントは『900px未満か以上か』でよいと思っている派ですが、このようにして定めることができます。

TypographyのTheme

Typographyはdeclareする型が異なりますのでご注意を。

src/styles/customTheme.ts
declare module '@mui/material/styles' {
  // 色とかbreakpointとか省略
}

// Typographyの型を上書きする
declare module '@mui/material/Typography' {
  interface TypographyPropsVariantOverrides {
    // 追加したいkeyを指定できる
    title: true;
    caption2: true;
    button: true;
  }
}
export const customTheme = createTheme({
  palette: {
    // 色の拡張など省略
  },
  breakpoints: {
    // 省略
  },
  // font-familyはここで指定できます
  typography: {
    fontFamily: ['HiraginoSans-W3', 'sans-serif'].join(','),
  },
})

// Typographyの各variantごとのスタイルはcreateThemeとは別に行う
// 既存のvariantも利用できる
// breakpointによりレスポンシブにも対応可能
customTheme.typography.h1 = {
  fontSize: 24,
  fontFamily: 'Noto Sans JP',
  fontWeight: 'bold',
  [customTheme.breakpoints.up('md')]: {
    fontSize: 40,
  },
};
customTheme.typography.title = {
  fontSize: 18,
  fontFamily: 'Noto Sans JP',
  fontWeight: 'bold',
  [customTheme.breakpoints.up('md')]: {
    fontSize: 34,
  },
};
customTheme.typography.caption = {
  fontSize: 11,
  fontFamily: 'HiraginoSans-W3',
};
customTheme.typography.caption2 = {
  fontSize: 10,
  fontFamily: 'HiraginoSans-W3',
};

Typographyコンポーネントに渡すpropsにより、スタイルをレスポンシブに切り替える設定ができました。

だいたいこれらの要素がかっちり固まれば、あとはコンポーネントを量産して組み合わせるだけで、UI構築が行えるかなと思います。

「各コンポーネントにどれだけの責務を負わせるか」というのは慣れで見極めていくしかない面もありますが、目安としては「コンポーネント名が示す意味以上のスタイルは与えない」という考えがよいかと思います。

例えば複数のボタンが横に並ぶレイアウトのとき、ボタンコンポーネントに横並びのスタイルや余白のスタイルを持たせるべきではありません。

横並びにする責務はMUI的にいうとStackにあり、ボタンはボタン以上の役割を持たないようにする設計が重要です。

🌏 おわりに

以上がMUIを自分用にカスタマイズし、かつAtomic Designで運用するときの私なりの考えでした。

なにか間違いやもっとよい方法などあればご教示いただきたいです。

長々とありがとうございました。

Discussion