4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Next.js14】MUIで作る最強のライト・ダークモード構築 ※ちらつき対策あり

Last updated at Posted at 2024-04-21

最近MUIを本気で好きになっております。どうもKadoです。

今回は、Material UI、Next.js(React)を使用したライトモード・ダークモードを構築してきます!

なお、今回の実装は、以下の点をしっかりサポートしています。

  • デバイスシーンの対応(createTheme)
  • どのコンポーネントからもシーンを変更できる(Context, Provider)
  • 選択したシーンの保持(Cookie, js-cookie)
  • ちらつきの防止(カスタムCSS, SSR, CSR)

最後の項目の、一瞬白色に(ライトモードとして)表示され、画面がチカっとする厄介な現象の対策も対応しているので安心してご覧ください!

また、技術としてカッコに記載した内容を使用していますが、基本コピペすることで使えるよう解説しています。

完成品

こんな出来栄え

ライトモード ダークモード
スクリーンショット 2024-04-19 8.30.42.png スクリーンショット 2024-04-19 8.30.51.png

リポジトリ

ディレクトリ構成

src
├── app # App Router
│   ├── favicon.ico
│   ├── globals.scss # ちらつきを防止するためのCSS
│   ├── layout.tsx # ThemeProviderを入れる
│   └── page.tsx # 表示されるページ
├── components # コンポーネントが格納される
│   └── LoadingContainer.tsx # ちらつきを防止するためのLoading画面
└── libs # 外部ライブラリの設定
    └── theme
        ├── EmotionCache.tsx # MUIのキャッシュファイル
        └── ThemeRegistry.tsx # MUIの設定ファイル
一応バージョンも
{
    "@emotion/react": "^11.11.4",
    "@emotion/styled": "^11.11.5",
    "@mui/material": "^5.15.15",
    "js-cookie": "^3.0.5",
    "next": "14.2.2",
    "react": "^18",
    "react-dom": "^18"
}
% yarn -v
1.22.21

Next.jsにMUIの導入

今回はNext.jsで進めていきますが、Reactでもほぼ同じ形となると思います。

# Next.jsプロジェクトの作成
yarn create next-app <プロジェクト名>
cd <プロジェクト名>

# MUIに必要なパッケージのインストール
yarn add @mui/material @emotion/react @emotion/styled

基本的に上記でMaterial UI自体は使用できるようになります。

# その他必要なやつ
yarn add js-cookie # CSRでCookieを設定するためのライブラリ
yarn add -D @types/js-cookie sass # .scssファイルを使用できるように

ソースコード一覧(コピペで完了)

ライブラリ設定

キャッシュファイル(EmotionCache.tsx)
src/libs/theme/EmotionCache.tsx
'use client';

import createCache from '@emotion/cache';
import type {
  EmotionCache,
  Options as OptionsOfCreateCache,
} from '@emotion/cache';
import { CacheProvider as DefaultCacheProvider } from '@emotion/react';
import { useServerInsertedHTML } from 'next/navigation';
import * as React from 'react';

export type NextAppDirEmotionCacheProviderProps = {
  /** This is the options passed to createCache() from 'import createCache from "@emotion/cache"' */
  options: Omit<OptionsOfCreateCache, 'insertionPoint'>;
  /** By default <CacheProvider /> from 'import { CacheProvider } from "@emotion/react"' */
  CacheProvider?: (props: {
    value: EmotionCache;
    children: React.ReactNode;
  }) => React.JSX.Element | null;
  children: React.ReactNode;
};

// Adapted from https://github.com/garronej/tss-react/blob/main/src/next/appDir.tsx
export const NextAppDirEmotionCacheProvider = (
  props: NextAppDirEmotionCacheProviderProps
) => {
  const { options, CacheProvider = DefaultCacheProvider, children } = props;

  const [registry] = React.useState(() => {
    const cache = createCache(options);
    cache.compat = true;
    const prevInsert = cache.insert;
    let inserted: { name: string; isGlobal: boolean }[] = [];
    cache.insert = (...args) => {
      const [selector, serialized] = args;
      if (cache.inserted[serialized.name] === undefined) {
        inserted.push({
          name: serialized.name,
          isGlobal: !selector,
        });
      }
      return prevInsert(...args);
    };
    const flush = () => {
      const prevInserted = inserted;
      inserted = [];
      return prevInserted;
    };
    return { cache, flush };
  });

  useServerInsertedHTML(() => {
    const inserted = registry.flush();
    if (inserted.length === 0) {
      return null;
    }
    let styles = '';
    let dataEmotionAttribute = registry.cache.key;

    const globals: {
      name: string;
      style: string;
    }[] = [];

    inserted.forEach(({ name, isGlobal }) => {
      const style = registry.cache.inserted[name];

      if (typeof style !== 'boolean') {
        if (isGlobal) {
          globals.push({ name, style });
        } else {
          styles += style;
          dataEmotionAttribute += ` ${name}`;
        }
      }
    });

    return (
      <>
        {globals.map(({ name, style }) => (
          <style
            key={name}
            data-emotion={`${registry.cache.key}-global ${name}`}
            dangerouslySetInnerHTML={{ __html: style }}
          />
        ))}
        {styles && (
          <style
            data-emotion={dataEmotionAttribute}
            dangerouslySetInnerHTML={{ __html: styles }}
          />
        )}
      </>
    );
  });

  return <CacheProvider value={registry.cache}>{children}</CacheProvider>;
};

MUI設定ファイル(ThemeRegistry.tsx)
src/libs/theme/ThemeRegistry.tsx
'use client';

import { PaletteMode, useMediaQuery } from '@mui/material';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import Cookies from 'js-cookie';
import React from 'react';
import { LoadingContainer } from '@/components/LoadingContainer';
import { NextAppDirEmotionCacheProvider } from '@/libs/theme/EmotionCache';

/**カラーモードの選択オプション */
export type ColorModeChoice = 'light' | 'dark' | 'device';

interface ColorModeContextType {
  /**選択中のカラーモード */
  selectedMode: ColorModeChoice;
  /**カラーモードを設定する関数 */
  toggleColorMode: (colorMode: ColorModeChoice) => void;
}

/**カラーモードのコンテキスト */
const ColorModeContext = React.createContext<ColorModeContextType>({
  selectedMode: 'light', // 仮の設定
  toggleColorMode: (colorMode: ColorModeChoice) => {
    colorMode; // 仮の設定
  },
});

/**MUIの設定プロバイダ */
export const ThemeRegistry = (props: {
  children: React.ReactNode;
  initColorMode: ColorModeChoice;
}) => {
  const prefersInit = useMediaQuery('(prefers-color-scheme: dark)')
    ? 'dark'
    : 'light';

  // ユーザが選択しているカラーモード
  const [selectedMode, setSelectedMode] = React.useState<ColorModeChoice>(
    props.initColorMode
  );

  /** 適用されるカラーモードの設定 */
  const mode = React.useMemo<PaletteMode>(
    () => (selectedMode !== 'device' ? selectedMode : prefersInit),
    [prefersInit, selectedMode]
  );

  // コンテキストの指定(他のコンポーネントでも呼び出して使えるように)
  const colorMode = React.useMemo(
    () => ({
      selectedMode,
      toggleColorMode: (colorMode: ColorModeChoice) => {
        Cookies.set('colorMode', colorMode);
        setSelectedMode(colorMode);
      },
    }),
    [selectedMode]
  );

  // ロード時にLoading画面を表示する
    const [mounted, setMounted] = React.useState<boolean>(false);

  React.useEffect(() => {
    setMounted(true);
  }, []);

  // カスタムシーン
  const theme = React.useMemo(
    () =>
      createTheme({
        palette: { mode },
      }),
    [mode]
  );

  return (
    <NextAppDirEmotionCacheProvider options={{ key: 'mui' }}>
      <ColorModeContext.Provider value={colorMode}>
        <ThemeProvider theme={theme}>
          <LoadingContainer isLoading={!mounted}>
            <CssBaseline />
            {props.children}
          </LoadingContainer>
        </ThemeProvider>
      </ColorModeContext.Provider>
    </NextAppDirEmotionCacheProvider>
  );
};

/**ColorModeContextを簡単に使うためのユーティリティ関数 */
export const useColorModeContext = (): ColorModeContextType =>
  React.useContext(ColorModeContext);

ローディングコンポーネント

ローディングコンテナ(LoadingContainer.tsx)
src/components/LoadingContainer.tsx
import { Box, CircularProgress } from '@mui/material';
import React from 'react';

interface LoadingContainerProps {
  isLoading: boolean;
  children: React.ReactNode;
}
export const LoadingContainer: React.FC<LoadingContainerProps> = (props) => {
  if (props.isLoading) {
    return (
      <Box
        width="100%"
        height="100%"
        display="flex"
        justifyContent="center"
        alignItems="center"
        position="fixed"
      >
        <CircularProgress />
      </Box>
    );
  }
  return props.children;
};

App Router系

初期状態反映CSS(globals.scss)
src/app/globals.scss
:root {
  --init-background: #fff;
}

@media screen and (prefers-color-scheme: dark) {
  :root {
    --init-background: #333;
  }
}

body {
  background: var(--init-background);
}

ボタンが入ったサンプルページ(page.tsx)
src/app/page.tsx
'use client';

import { Box, Button, Paper, Stack } from '@mui/material';
import { useColorModeContext } from '@/libs/theme/ThemeRegistry';

const Home = () => {
  const { toggleColorMode, selectedMode } = useColorModeContext();
  return (
    <Paper component={Box} p={2}>
      <Stack spacing={1}>
        <Button
          variant={selectedMode === 'light' ? 'contained' : 'outlined'}
          onClick={() => toggleColorMode('light')}
        >
          ライト
        </Button>
        <Button
          variant={selectedMode === 'device' ? 'contained' : 'outlined'}
          onClick={() => toggleColorMode('device')}
        >
          システム
        </Button>
        <Button
          variant={selectedMode === 'dark' ? 'contained' : 'outlined'}
          onClick={() => toggleColorMode('dark')}
        >
          ダーク
        </Button>
      </Stack>
    </Paper>
  );
};
export default Home;

レイアウト(layout.tsx)
src/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { cookies } from 'next/headers';
import '@/app/globals.scss';
import { ColorModeChoice, ThemeRegistry } from '@/libs/theme/ThemeRegistry';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  const preColorMode = cookies().get('colorMode')?.value;
  const initColorMode: ColorModeChoice =
    preColorMode === 'light' || preColorMode === 'dark'
      ? preColorMode
      : 'device';

  // 初期表示のカスタムスタイルを適用
  let style: any = {};
  if (['light', 'dark'].includes(initColorMode)) {
    style['--init-background'] = initColorMode === 'dark' ? '#333' : '#fff';
  }

  return (
    <html lang="ja" style={style}>
      <body className={inter.className}>
        <ThemeRegistry initColorMode={initColorMode}>{children}</ThemeRegistry>
      </body>
    </html>
  );
}

コードの解説

カスタムシーンの導入

createThemeを使用して、MUI標準のカラーを変更することができます。標準のカラーやダークモードのカラーは公式ページを見ると一通り記載されています。カスタムでカラーを変更したい場合もcreateTheme上に設定を記述することでできます。

カスタムシーンを導入する際は、MUIで用意されている<ThemeProvider>を使用してthemeをぶち込みます。

src/libs/theme/ThemeRegistry.tsx
'use client';

import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import React from 'react';

export const ThemeRegistry = (props: { children: React.ReactNode }) => {
  // カスタムシーン
  const theme = createTheme({
    palette: { mode: 'dark' }, // ここを'light', 'dark'と設定すると一発で変わる
  });

  return (
    <ThemeProvider theme={theme}>
      <CssBaseline />
      {props.children}
    </ThemeProvider>
  );
};

作成したThemeRegistryをlayout.tsxに反映します。

layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { ThemeRegistry } from '@/libs/theme/ThemeRegistry';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja">
      <body className={inter.className}>
        <ThemeRegistry>{children}</ThemeRegistry> {/* こんな感じでchildrenを挟む */}
      </body>
    </html>
  );
}

page.tsxに表示するコンテンツを仮で作成します。

page.tsx
'use client';

import { Box, Button, Paper, Stack } from '@mui/material';

export default function Home() {
  return (
    <Paper component={Box} p={2}>
      <Stack spacing={1}>
        <Button variant="outlined">ライト</Button>
        <Button variant="outlined">システム</Button>
        <Button variant="outlined">ダーク</Button>
      </Stack>
    </Paper>
  );
}

image.png

このレイアウトだけでも非常に綺麗ですね🤗

デバイステーマでライト・ダークモードを実装する

デバイスと同じテーマで実装する方法ですが、これもMUIが用意してくれるuseMediaQueryを使用します。

先程のThemeRegistry.tsxにて、一部追記をします。

ThemeRegistry.tsx
'use client';

+ import { useMediaQuery } from '@mui/material';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import React from 'react';

export const ThemeRegistry = (props: { children: React.ReactNode }) => {
+  // メディアクエリの真偽値を取得
+  const mode = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';

  // カスタムシーン
  const theme = createTheme({
-    palette: { mode: 'dark' },
+    palette: { mode },
  });

  return (
    <ThemeProvider theme={theme}>
      <CssBaseline />
      {props.children}
    </ThemeProvider>
  );
};

たったこれだけで、デバイスのテーマに応じてライト・ダークモードが変更されて表示されるようになります。

が、ダークモードの際チラつきが発生

再読み込みをすると、恐らく一瞬だけ白く光る表示がされたと思います。これはレンダリングの関係でJavaScriptが完全に読み込まれるまでライトモードで表示されるために起こることです。CSRでサイトを構築すると、どうしても時差が発生してしまいます。

一応必死に解決策を調べようと公式サイトを覗いてみましたが、まさかの公式サイトすらやらかしていました…

image.gif

最後にこれの対処法も紹介するので、しばしお待ちを。

切り替えスイッチでカスタマイズできるようにする

切り替えスイッチを使用してライト・システム・ダークを変更できるようにします。
ThemeRegistry.tsxのmodeを変更する形となりますが、実際は別の子コンポーネントからモード変更できるようにするため、Context, Providerを使用します。

Context, Providerを使用することで、Contextで囲った全てのコンポーネントから処理を呼び出して共通のデータを取得・更新することができます。

もともと<ThemeProvider>が似た性質を持っていますが、colorModeを変更する専用のContext, Providerを追加します。

修正後のThemeRegistry.tsx(長め)
'use client';

import { PaletteMode, useMediaQuery } from '@mui/material';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import React from 'react';

/**カラーモードの選択オプション */
export type ColorModeChoice = 'light' | 'dark' | 'device';

interface ColorModeContextType {
  /**選択中のカラーモード */
  selectedMode: ColorModeChoice;
  /**カラーモードを設定する関数 */
  toggleColorMode: (colorMode: ColorModeChoice) => void;
}

/**カラーモードのコンテキスト */
const ColorModeContext = React.createContext<ColorModeContextType>({
  selectedMode: 'light', // 仮の設定
  toggleColorMode: (colorMode: ColorModeChoice) => {
    colorMode; // 仮の設定
  },
});

/**MUIの設定プロバイダ */
export const ThemeRegistry = (props: { children: React.ReactNode }) => {
  const prefersInit = useMediaQuery('(prefers-color-scheme: dark)')
    ? 'dark'
    : 'light';

  // ユーザが選択しているカラーモード
  const [selectedMode, setSelectedMode] =
    React.useState<ColorModeChoice>('light');

  /** 適用されるカラーモードの設定 */
  const mode = React.useMemo<PaletteMode>(
    () => (selectedMode !== 'device' ? selectedMode : prefersInit),
    [prefersInit, selectedMode]
  );

  // コンテキストの指定(他のコンポーネントでも呼び出して使えるように)
  const colorMode = React.useMemo(
    () => ({
      selectedMode,
      toggleColorMode: (colorMode: ColorModeChoice) => {
        setSelectedMode(colorMode);
      },
    }),
    [selectedMode]
  );

  // カスタムシーン
  const theme = React.useMemo(
    () =>
      createTheme({
        palette: { mode },
      }),
    [mode]
  );

  return (
    <ColorModeContext.Provider value={colorMode}>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        {props.children}
      </ThemeProvider>
    </ColorModeContext.Provider>
  );
};

/**ColorModeContextを簡単に使うためのユーティリティ関数 */
export const useColorModeContext = (): ColorModeContextType =>
  React.useContext(ColorModeContext);

作成したcontextの内容をコンポーネントで操作するため、page.tsxも変更します。

contextを追加したpage.tsx
page.tsx
'use client';

import { Box, Button, Paper, Stack } from '@mui/material';
import { useColorModeContext } from '@/libs/theme/ThemeRegistry';

const Home = () => {
  const { toggleColorMode, selectedMode } = useColorModeContext();
  return (
    <Paper component={Box} p={2}>
      <Stack spacing={1}>
        <Button
          variant={selectedMode === 'light' ? 'contained' : 'outlined'}
          onClick={() => toggleColorMode('light')}
        >
          ライト
        </Button>
        <Button
          variant={selectedMode === 'device' ? 'contained' : 'outlined'}
          onClick={() => toggleColorMode('device')}
        >
          システム
        </Button>
        <Button
          variant={selectedMode === 'dark' ? 'contained' : 'outlined'}
          onClick={() => toggleColorMode('dark')}
        >
          ダーク
        </Button>
      </Stack>
    </Paper>
  );
};
export default Home;

useColorModeContextは今回独自で作成したものです。selectedModeで現在選択されているカラーモードを取得し、toggleColorModeでカラーモードを更新するようにしています。

これで、ボタンを押して切り替えができるようになりました。

再読込時もカラーモードを保持する

現状は再読み込みをするとカラーモードがリセットされてしまいます。そこで、Cookieを使用してカラーモードを取得するようにします。

ThemeRegistry.tsx
+ import Cookies from 'js-cookie';

// ・・・省略・・・

/**MUIの設定プロバイダ */
- export const ThemeRegistry = (props: { children: React.ReactNode }) => {
+ export const ThemeRegistry = (props: {
+   children: React.ReactNode;
+   initColorMode: ColorModeChoice; // この行を追加
+ }) => {
  const prefersInit = useMediaQuery('(prefers-color-scheme: dark)')
    ? 'dark'
    : 'light';

  // ユーザが選択しているカラーモード
-   const [selectedMode, setSelectedMode] =
-    React.useState<ColorModeChoice>('light');
+  const [selectedMode, setSelectedMode] = React.useState<ColorModeChoice>(
+    props.initColorMode // 取得した引数の値を設置
+  );
	
  /** 適用されるカラーモードの設定 */
  const mode = React.useMemo<PaletteMode>(
    () => (selectedMode !== 'device' ? selectedMode : prefersInit),
    [prefersInit, selectedMode]
  );

  // コンテキストの指定(他のコンポーネントでも呼び出して使えるように)
  const colorMode = React.useMemo(
    () => ({
      selectedMode,
      toggleColorMode: (colorMode: ColorModeChoice) => {
+        Cookies.set('colorMode', colorMode);
        setSelectedMode(colorMode);
      },
    }),
    [selectedMode]
  );

// ・・・下に続く・・・
layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
+ import { cookies } from 'next/headers';
- import { ThemeRegistry } from '@/app/ThemeRegistry';
+ import { ColorModeChoice, ThemeRegistry } from '@/libs/theme/ThemeRegistry';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
+ // Cookieから保存されたカラーモードを取得
+  const preColorMode = cookies().get('colorMode')?.value;
+  const initColorMode: ColorModeChoice =
+    preColorMode === 'light' || preColorMode === 'dark'
+      ? preColorMode
+      : 'device';

  return (
    <html lang="ja">
      <body className={inter.className}>
-        <ThemeRegistry>{children}</ThemeRegistry>
+        <ThemeRegistry initColorMode={initColorMode}>{children}</ThemeRegistry>
      </body>
    </html>
  );
}

ここで、cookieの取得をlayout.tsx、cookieの更新をThemeRegistry.tsxで行っている理由は、ダークモード時のちらつきを抑えるためです。

これをThemeRegistry単体、要するにCSRで完結させてしまうと、やはりJavaScriptでのmode変更となるためラグが発生します。そこで、layout.tsxにCookieのデータを取得する処理を記述して反映、要するにSSRで設定することで、読み込み時には既にダークモードが適用されて表示することができます!

しかしデバイスモードでは未だちらつきが発生

しかし、deviceモードの場合はそう簡単には行きません。これがCSS単体であれば問題なく動作しますが、今回はuseMediaQueryのため動的な動作となり、チラつきが発生してしまいます。

デバイスモードでのちらつきを改善する

最後はデバイスモードでのちらつきを改善する方法です。少し強制的な形になりますが、デバイステーマが反映されるまでLoadingの表示を付けてちらつきを解消します。実際にやることは以下のとおりです。

  1. Loading特化のコンポーネントを作成する
  2. ThemeRegistryツリーにLoadingコンポーネントを配置
  3. mountというHookでJavaScriptがロードされたかを検知
  4. CSSで初期背景を設定する
  5. layout.tsxでさらにカスタムスタイルを導入

Loading特化のコンポーネントを作成する

ローディングのコンテナコンポーネント(LoadingContainer.tsx)
src/components/LoadingContainer.tsx
import { Box, CircularProgress } from '@mui/material';
import React from 'react';

interface LoadingContainerProps {
  isLoading: boolean;
  children: React.ReactNode;
}
export const LoadingContainer: React.FC<LoadingContainerProps> = (props) => {
  if (props.isLoading) {
    // ローティング中はローディングの表示  
    return (
      <Box
        width="100%"
        height="100%"
        display="flex"
        justifyContent="center"
        alignItems="center"
        position="fixed"
      >
        <CircularProgress />
      </Box>
    );
  }
  // ロードが終わっていたらchildrenのみ表示
  return props.children;
};

mountというHookでJavaScriptがロードされたかを検知しLoadingを表示

このmountという変数は、useEffectでtrueにします。useEffectはCSRでしか実行されないです。つまり、useEffectが実行され、mountがtrueになった、ということは、レンダリングが完了した、ということとなります(それまではmodeに本来のカラーモードが挿入されていないため、ちらつきが発生する原因となる)。

src/libs/theme/ThemeRegistry.tsx
+ import { LoadingContainer } from '@/components/LoadingContainer';
// 省略

+  // ロード時にLoading画面を表示する
+  const [mounted, setMounted] = React.useState<boolean>(
+    props.initColorMode !== 'device' // デバイスモード時にfalseを表示し、Loadingが表示されるようにする
+  );

+  React.useEffect(() => {
+    setMounted(true);
+  }, []);

  return (
    <ColorModeContext.Provider value={colorMode}>
      <ThemeProvider theme={theme}>
-        <CssBaseline />
-        {props.children}
+        <LoadingContainer isLoading={!mounted}>
+          <CssBaseline />
+          {props.children}
+        </LoadingContainer>
      </ThemeProvider>
    </ColorModeContext.Provider>
  );
};

// ・・・下に続く・・・

これで、ページをロードした際にデバイスモード時にLoadingが表示されたと思います。しかし、デバイスモードがダークモードだった際、Loadingの背景がライトモードの白色のためやはり根本の目に悪い白い画面がちらついています。

CSSで初期背景を設定する

今度はCSSも参戦し、最初に表示されるロード時のちらつきを排除します。

CSS(globals.scss)
src/app/globals.scss
:root {
  --init-background: #fff;
}

@media screen and (prefers-color-scheme: dark) {
  :root {
    --init-background: #333;
  }
}

body {
  background: var(--init-background);
}

src/app/layout.tsx
// CSSを読み込む行を追加
import '@/app/globals.scss';

上記の対応で、ちらつき防止のLoadingが正常に導入され、device選択時のチラつきを排除することができました!

しかし、今度はライト・ダーク時の背景が少しおかしくなっていると思います。これは、@media設定でライト・ダークの背景が適用されたためです。

layout.tsxでさらにカスタムスタイルを導入

src/apps/layout.tsx
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  // Cookieから保存されたカラーモードを取得
  const preColorMode = cookies().get('colorMode')?.value;
  const initColorMode: ColorModeChoice =
    preColorMode === 'light' || preColorMode === 'dark'
      ? preColorMode
      : 'device';

+  // 初期表示のカスタムスタイルを適用
+  let style: any = {};
+  if (['light', 'dark'].includes(initColorMode)) {
+    style['--init-background'] = initColorMode === 'dark' ? '#333' : '#fff';
+  }

  return (
-   <html lang="ja">
+   <html lang="ja" style={style}>
      <body className={inter.className}>
        <ThemeRegistry initColorMode={initColorMode}>{children}</ThemeRegistry>
      </body>
    </html>
  );
}

css変数をhtmlタグに追加することで、ライト・ダークの背景もSSRで強制的に指定することができ、先程の問題及び、ちらつきを解消したライト・ダークテーマを作成することができました!

ライト・ダーク選択時でもローディング画面を適用する

今回の場合デバイスモード時にだけローディングが表示される形ですが、これをすべて共通してローディングを実施することも可能です。

src/libs/theme/ThemeRegistry.tsx
  // ロード時にLoading画面を表示する
-  const [mounted, setMounted] = React.useState<boolean>(
-    props.initColorMode !== 'device' // デバイスモード時にfalseを表示し、Loadingが表示されるようにする
-  );
+  const [mounted, setMounted] = React.useState<boolean>(false);

キャッシュファイルの導入

更に最適化したい人向けですが、EmotionCacheを導入することで、さらに読み込みが早くなるとかならないとか。

キャッシュファイル(EmotionCache.tsx)
src/libs/theme/EmotionCache.tsx
'use client';

import createCache from '@emotion/cache';
import type {
  EmotionCache,
  Options as OptionsOfCreateCache,
} from '@emotion/cache';
import { CacheProvider as DefaultCacheProvider } from '@emotion/react';
import { useServerInsertedHTML } from 'next/navigation';
import * as React from 'react';

export type NextAppDirEmotionCacheProviderProps = {
  /** This is the options passed to createCache() from 'import createCache from "@emotion/cache"' */
  options: Omit<OptionsOfCreateCache, 'insertionPoint'>;
  /** By default <CacheProvider /> from 'import { CacheProvider } from "@emotion/react"' */
  CacheProvider?: (props: {
    value: EmotionCache;
    children: React.ReactNode;
  }) => React.JSX.Element | null;
  children: React.ReactNode;
};

// Adapted from https://github.com/garronej/tss-react/blob/main/src/next/appDir.tsx
export const NextAppDirEmotionCacheProvider = (
  props: NextAppDirEmotionCacheProviderProps
) => {
  const { options, CacheProvider = DefaultCacheProvider, children } = props;

  const [registry] = React.useState(() => {
    const cache = createCache(options);
    cache.compat = true;
    const prevInsert = cache.insert;
    let inserted: { name: string; isGlobal: boolean }[] = [];
    cache.insert = (...args) => {
      const [selector, serialized] = args;
      if (cache.inserted[serialized.name] === undefined) {
        inserted.push({
          name: serialized.name,
          isGlobal: !selector,
        });
      }
      return prevInsert(...args);
    };
    const flush = () => {
      const prevInserted = inserted;
      inserted = [];
      return prevInserted;
    };
    return { cache, flush };
  });

  useServerInsertedHTML(() => {
    const inserted = registry.flush();
    if (inserted.length === 0) {
      return null;
    }
    let styles = '';
    let dataEmotionAttribute = registry.cache.key;

    const globals: {
      name: string;
      style: string;
    }[] = [];

    inserted.forEach(({ name, isGlobal }) => {
      const style = registry.cache.inserted[name];

      if (typeof style !== 'boolean') {
        if (isGlobal) {
          globals.push({ name, style });
        } else {
          styles += style;
          dataEmotionAttribute += ` ${name}`;
        }
      }
    });

    return (
      <>
        {globals.map(({ name, style }) => (
          <style
            key={name}
            data-emotion={`${registry.cache.key}-global ${name}`}
            dangerouslySetInnerHTML={{ __html: style }}
          />
        ))}
        {styles && (
          <style
            data-emotion={dataEmotionAttribute}
            dangerouslySetInnerHTML={{ __html: styles }}
          />
        )}
      </>
    );
  });

  return <CacheProvider value={registry.cache}>{children}</CacheProvider>;
};

ThemeRegistryにキャッシュプロバイダを導入
ThemeRegistry.tsx

import { NextAppDirEmotionCacheProvider } from '@/libs/theme/EmotionCache';

// ・・・省略・・・
   return (
     <NextAppDirEmotionCacheProvider options={{ key: 'mui' }}> {/*←プロバイダを追加*/}
      <ColorModeContext.Provider value={colorMode}>
        <ThemeProvider theme={theme}>
          <LoadingContainer isLoading={!mounted}>
            <CssBaseline />
            {props.children}
          </LoadingContainer>
        </ThemeProvider>
      </ColorModeContext.Provider>
    </NextAppDirEmotionCacheProvider>
  );

最後に

この記事を書くにあたって、元々LocalStorageを用いて記事を記述していましたが、全ての組み合わせ(デバイスがlight or dark, ユーザでlight or device or dark)を試しているうちに、再び「またちらつきが発生している」ことを発見し急遽cookieを使いつつ、SSRも併用して更新する方式にたどり着きました。

最強を自称するなら無視せず欠点を潰して挑みたいと感じ、今回の形に至りました。これにたどり着くには様々な概念を知っている必要があるため、なかなか難易度は高いなと感じました。

この記事を見て、もっとMUIを使う人が増えて、かつライト・ダークモード両対応のWebアプリが増えてくれればいいな、と思っております。ぜひとも私の睡眠の質を守るためにもフロントエンド、モバイルエンジニアの方ダークモードの対応よろしくお願いいたします🥺

参考記事

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?