2021.03.23

Material-UIを使ってGatsbyの見た目を整える


デザインセンスがないので、フロントの開発では趣味でも仕事でもCSSフレームワークを使うことが多いです。

当サイトもMaterial-UIというCSSフレームワークを使って構築しています。

カスタマイズで試行錯誤したところがあったので、備忘録も兼ねてGatsbyでMaterial-UIをどのように使っているか説明します。

Material-UI導入

まずはMaterial-UIをインストールします。

npm install @material-ui/core
# Material Iconを使う場合はこちらも
npm install @material-ui/icons

続いてGatsbyでMaterial-UIを扱いやすくするプラグインを入れます。

npm install gatsby-plugin-material-ui

FOUCの解決、ベンダープレフィックスの追加、CSSの最小化をしてくれます。

インストールしたらgatsby-config.jsのプラグインに追加します。

plugins: ['gatsby-plugin-material-ui']

これでGatsbyでMaterial-UIを使えるようになりました!

テーマをサイト全体に適用する

Material-UIにはテーマという仕組みがあり、コンポーネントの色や文字の大きさなどを一元管理してカスタマイズできます。

カスタマイズしたテーマをGatsbyで生成するページすべてに適用するため、Gatsby Browser APIsのwrapRootElementというAPIを使います。

まずはMaterial-UIのテーマを適用するコンポーネントを作ります。

import { createMuiTheme, CssBaseline } from '@material-ui/core'
import { ThemeProvider } from '@material-ui/styles'
import React from 'react'
const MuiThemeProvider: React.FC = ({ children }) => {
const theme = createMuiTheme({
palette: {
primary: {
main: '#fff',
},
},
})
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
)
}
export default MuiThemeProvider

CssBaselinenormalize.cssのようなコンポーネントで、Material-UIを使うにあたって適当なデフォルトCSSを提供してくれます。

gatsby-browser.jsでこのコンポーネント使ってルートをラップします。

import { WrapRootElementBrowserArgs } from 'gatsby'
import React, { ReactNode } from 'react'
import MuiThemeProvider from '../components/mui-theme-provider'
export const wrapRootElement = ({
element,
}: WrapRootElementBrowserArgs): ReactNode => {
return <MuiThemeProvider>{element}</MuiThemeProvider>
}

これですべてのページでテーマが適用されるようになりました!

各コンポーネントで以下のようにテーマの値を取り出すことができます。

import { makeStyles, useTheme } from '@material-ui/core'
const theme = useTheme()
// mui-theme-provider.tsxで定義したthemeが得られる
// theme.palette.primary.main => '#fff'
const useStyles = makeStyles(theme => {
// mui-theme-provider.tsxで定義したthemeが得られる
// theme.palette.primary.main => '#fff'
})

個人的はまりポイント

最初は各ページで利用しているlayout.tsxコンポーネントのなかでテーマを設定しようとしてました。(テーマと関係ないコードは省略しています)

const Layout: React.FC<Props> = ({ title, children }) => {
const theme = useTheme()
// theme.palette.primary.main => '#3f51b5' default value!!!
return (
<ThemeProvider>
<CssBaseline />
<AppBar position="static" elevation={1}>
// 省略
</AppBar>
<main>{children}</main>
<footer>
// 省略
</footer>
</ThemeProvider>
)
}
export default Layout
const BlogIndex: React.FC<PageProps<BlogIndexQuery>> = ({ data }) => {
const siteTitle = data.site?.siteMetadata?.title || 'Title'
const posts = data.allContentfulBlogPost.nodes
const theme = useTheme()
// theme.palette.primary.main => '#3f51b5' default value!!!
return (
<Layout title={siteTitle}>
<SEO />
<ArticleList title="All Posts" posts={posts} />
</Layout>
)
}

これだとテーマは正しく適用されません。
layout.tsxindex.tsxが、ThemeProviderの子になっていないからです。

ThemeProviderはテーマを適用したいコンポーネントの親である必要があります。 上記のコードだとindex.tsxがルートのコンポーネントであり、その中でThemeProviderを呼び出している形になります。

ページ自体にテーマを適用するにはwrapRootElementを使ってindex.tsxを呼び出すよりも上のコンポーネントとして定義する必要がありました。

ダークモードを実装

Material-UIにはダークモードがあらかじめ用意されていて、palette.typeに'dark'を設定するだけでダークモードになります。

const darkTheme = createMuiTheme({
palette: {
type: 'dark',
},
})

ダークモードの切り替えを実装する

今回はダークモードを切り替えできるように実装します。

stateでダークモードの状態を保持し、リロードした際にも引き継がれるように、Local Storageに保存しておきます。

まず、palette.typeの値を保持するstateを作成します。

const [paletteType, setPaletteType] = useState<string | null>(null)

次にlocalStorageからpaletteTypeを読み出します。 localStorageはレンダー後でないと変数が取得できないので注意が必要です。

useEffect(() => {
setPaletteType(localStorage.getItem('paletteType'))
})

paletteTypeの値を'light'/'dark'で切り替える関数を定義します。切り替えたタイミングでLocal Storageにも保存しています。

const togglePaletteType = () => {
const newPaletteType = paletteType === 'light' ? 'dark' : 'light'
setPaletteType(newPaletteType)
localStorage.setItem('paletteType', newPaletteType)
}

paletteTypetogglePaletteTypeを共有するため、コンテクストを使用します。 これによって好きなコンポーネントでダークモードの切り替えが行えるようになります。

export const paletteTypeContext = createContext<
[string | null, (() => void) | undefined]
>([null, undefined])

作成したコンテクストのプロバイダを仕込みます。

<ThemeProvider theme={theme}>
<CssBaseline />
<paletteTypeContext.Provider value={[paletteType, togglePaletteType]}>
{children}
</paletteTypeContext.Provider>
</ThemeProvider>

以上で、mui-theme-provider.tsxの修正は完了です。

完成コード

import { createMuiTheme, CssBaseline } from '@material-ui/core'
import { grey } from '@material-ui/core/colors'
import { ThemeProvider } from '@material-ui/styles'
import React, { createContext, useEffect, useMemo, useState } from 'react'
export const paletteTypeContext = createContext<
[string | null, (() => void) | undefined]
>([null, undefined])
const MuiThemeProvider: React.FC = ({ children }) => {
const [paletteType, setPaletteType] = useState<string | null>(null)
useEffect(() => {
setPaletteType(localStorage.getItem('paletteType'))
})
const togglePaletteType = () => {
const newPaletteType = paletteType === 'light' ? 'dark' : 'light'
setPaletteType(newPaletteType)
localStorage.setItem('paletteType', newPaletteType)
}
const theme = useMemo(
() =>
createMuiTheme({
palette: {
type: paletteType === 'dark' ? 'dark' : undefined,
},
}),
[paletteType]
)
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<paletteTypeContext.Provider value={[paletteType, togglePaletteType]}>
{children}
</paletteTypeContext.Provider>
</ThemeProvider>
)
}
export default MuiThemeProvider

切り替えボタンを実装する

useContextで上記で実装したpaletteTypetogglePaletteTypeが使用できるようになります。

import React, { useContext } from 'react'
import { paletteTypeContext } from './mui-theme-provider'
const [paletteType, togglePaletteType] = useContext(paletteTypeContext)

今回はこれをヘッダーに仕込みました。

const Layout: React.FC<Props> = ({ title, children }) => {
const [paletteType, togglePaletteType] = useContext(paletteTypeContext)
const classes = useStyle()
return (
<React.Fragment>
<AppBar position="static" elevation={1}>
<Toolbar variant="dense">
// 省略
<IconButton
onClick={togglePaletteType}
color="inherit"
aria-label="toggle theme"
>
{paletteType === 'dark' ? <Brightness4 /> : <BrightnessHigh />}
</IconButton>
</Toolbar>
</AppBar>
<main>{children}</main>
<footer>
// 省略
</footer>
</React.Fragment>
)
}
export default Layout

まとめ

Material-UIのテーマをすべてのページに適用するため、wrapRootElementという仕組みを使いました。また、コンテクストを使うことでテーマを切り替えられるようにしています。

あまり使ったことがなかったコンテクストやGatsbyの機能の知見が得られて勉強になりました。 ReactやGatsbtの経験はまだまだなので、「こうしたほうがいい」とか「これはおかしい」という点があれば気軽にコメントください!