Nextjs x Emotion x Storybookでテーマを切り替えてみる
Emotion, styled componentsなどのcss-in-jsライブラリ、またはMUIなどのUIライブラリはそれぞれthemingの機能を持ちます。
個人開発でEmotionとStorybookを利用していますが、storybookのテーマの対応について色々悩まされました。(StorybookのAPIが変更されたり、使いたいアドオンが古くなったり、公式の記事や他の有志の記事が古くなってしまったため、うまく機能しませんでしたT T)
やっと解決できたので、Emotionのテーマを使う際にStorybookでテーマを反映できるのとStorybookのUIから簡単にテーマの切替を操作できるようにする方法を紹介しようと思います。(他のライブラリのテーマもさほど違いがないので参考になれば嬉しいです)
ながれ
今回は4ステップ分けて説明しようと考えてます。
-
Nextjs x Emotionでテーマを使う(切り替えなし)
-
Storybookのテーマ反映(切り替えできる)
-
Nextjs x Emotionでテーマを切り替える
-
アプリ側でテーマ切り替え追加後のStorybookでのテーマの切り替え対応
あとおまけに、StorybookでEmotionのスタイルが反映されない時の対策も追加します。
リソース
リポジトリ
ぜひ参考としてご利用ください。
https://github.com/f3if3i/nextjs-emotion-storybook-theming-boilerplate
-
main
のブランチにテーマ切り替えのコードが入ります(ステップ3と4) -
unswitchable
のブランチにアプリ側テーマ切り替えなし、Storybook側テーマ切り替えありのコードが入ります(ステップ1と2)
利用したライブラリ
- devDependencies
"@emotion/babel-preset-css-prop": "^11.11.0",
"@storybook/addon-essentials": "^7.0.23",
"@storybook/addon-interactions": "^7.0.23",
"@storybook/addon-links": "^7.0.23",
"@storybook/blocks": "^7.0.23",
"@storybook/nextjs": "^7.0.23",
"@storybook/react": "^7.0.23",
"@storybook/testing-library": "^0.0.14-next.2",
"eslint-plugin-storybook": "^0.6.12",
"storybook": "^7.0.23"
- dependecies
"@emotion/react": "^11.11.1",
"@types/node": "20.3.1",
"@types/react": "18.2.13",
"@types/react-dom": "18.2.6",
"eslint": "8.43.0",
"eslint-config-next": "13.4.7",
"next": "13.4.7",
"react": "18.2.0",
"react-dom": "18.2.0",
"typescript": "5.1.3"
下準備
- NextjsかReactのinit(TypeScript使ってます!)
- Emotionの導入
- Storybookの導入
Emotionでテーマを作ってみる
まず今回で使うテーマを作る
srcの配下にtheme.ts
を作成しdefaultとvivid二種類のテーマを用意しました。(型もついでに!)
export interface Theme {
colors: {
primary: {
main: string;
};
caution: {
main: string;
};
white: {
main: string;
};
black: {
main: string;
};
grey: {
100: string;
200: string;
300: string;
};
};
}
export const defaultTheme: Theme = {
colors: {
primary: { main: "#ffcc00" },
caution: { main: "#EE4521" },
white: { main: "#FFFFFF" },
black: {
main: "#1B1C1E",
},
grey: {
100: "#F5F5F5",
200: "#EEEBE4",
300: "#B0B0B0"
},
}
}
export const vividTheme: Theme = {
colors: {
primary: { main: "#99ff00" },
caution: { main: "#EE4521" },
white: { main: "#FFFFFF" },
black: {
main: "#1B1C1E",
},
grey: {
100: "#F5F5F5",
200: "#EEEBE4",
300: "#B0B0B0"
},
}
}
EmotionのThemeProviderを使ってテーマを導入
今回Next13とApp directoryを使ってますのでやや見た目変わります。
グロバールで利用できるようにsrc/app/layout.tsxにEmotionのThemeProviderを導入しました。
ThemeProviderにdefaultThemeを渡します。
(前のバージョンのNextもしくはReactは_app.tsに入れても一緒です)
(Next 13ではhooksを使う場合、ファイルに"use client"
を追加しないといけないので要注意)
"use client"
import { ThemeProvider as EmotionThemeProvider } from "@emotion/react"
import { Inter } from "next/font/google"
import { defaultTheme } from "../theme"
const inter = Inter({ subsets: ["latin"] })
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<EmotionThemeProvider theme={defaultTheme}>
<body className={inter.className}>{children}</body>
</EmotionThemeProvider>
</html>
)
}
テーマを利用するボタンコンポーネントを作成
ラベルとonClickだけpropsとして渡されるシンプルなコンポーネントを作成します。
emotionのuseTheme
を使って、先ほどThemeProviderに渡されたdefaultのテーマを取得することができます。(取得できたテーマの型はうまく認識されてないのようでas Themeで指定する)
テーマから必要な色を取ってstyleに入れて完成です。
import { css, useTheme } from "@emotion/react"
import { Theme } from "@/theme"
interface ButtonProps {
label: string;
onClick?: () => void;
}
export const Button = ({
label,
...props
}: ButtonProps) => {
// emotionの機能
const theme = useTheme() as Theme
const style = getStyles(theme)
return (
<button
type="button"
css={style.button}
{...props}
>
{label}
</button >
)
}
const getStyles = (theme: Theme) => {
return ({
button: css({
color: `${theme.colors.primary.main}`,
backgroundColor: `${theme.colors.black.main}`,
padding: "16px 36px",
borderRadius: "26px",
fontSize: "20px",
fontWeight: "700"
})
})
}
ここまでの作業でできたボタン
テーマからprimaryのカラーの反映ができました!
ただし、テーマの反映はできましたが、まだ切り替えできないので進みます
Storybookでのテーマ反映
EmotionのThemeProviderを使っているので、そのままコンポーネントをStorybookを入れるだけではテーマの反映ができないのため、decoratorを使います。
また、UIからわかりやすく操作できるようStorybookのツールバーにボタンを入れます。ボタンをクリックするとプルダウン(defaultとvivid)が現れテーマを選択できるようになります。
完成物のイメージ
Storybookのpreviewを修正
まず.storybook/preview.ts
の修正です。デフォルトでtsファイルになってますが、tsx記法を使いたいので.tsxに変更します。
ここで行ったのは、
- themeというglobalTypesを追加します。themeは「default」というvalueを持ちます。また、ツールバーでも表示できるようtoolbarの設定も追加します。
- EmotionのThemeProviderを使うために、
withThemeProvider
というdecoratorの作成。関数内でglobalTypesのthemeのvalueからテーマを取得してThemeProviderに入れます。
// preview.tsx
import { ThemeProvider as EmotionThemeProvider } from "@emotion/react"
import { type Preview } from "@storybook/react"
import React from "react";
import { defaultTheme, vividTheme } from '../src/theme'
// テーマを取得するヘルパー
const getTheme = (themeName) => {
if (themeName === "vivid") {
return vividTheme
}
return defaultTheme
}
// decorator作成
const withThemeProvider = (Story, context) => {
// globalTypesのthemeのvalue(defaultもしくはvivid)でthemeを取得
const theme = getTheme(context.globals.theme)
return (
// defaultThemeを反映
<EmotionThemeProvider theme={theme}>
<Story />
</EmotionThemeProvider>
)
}
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
// globalType追加
globalTypes: {
theme: {
description: 'Global theme for components',
defaultValue: 'default',
// toolbarのタイトル、アイコンとアイテム(複数追加可能)の設定
toolbar: {
title: 'Theme',
icon: 'circlehollow',
items: ['default', 'vivid'],
dynamicTitle: true,
},
},
},
decorators: [withThemeProvider]
}
export default preview
ボタンコンポーネントのstories作成
storiesディレクトリの配下にButton.stories.tsx
を作成します。
label
のpropsにButtonの文字列を入れるだけシンプルな構成になります。
import type { Meta, StoryObj } from "@storybook/react"
import { Button } from "../../components/Button"
const meta: Meta<typeof Button> = {
title: "Example/Button",
component: Button,
tags: ["autodocs"],
}
export default meta
type Story = StoryObj<typeof Button>
export const Primary: Story = {
args: {
label: "Button",
},
}
これでアプリ側のテーマ反映とStorybookのテーマ反映、切り替えができました!
続いて、本題のアプリ側のテーマ切り替えの機能も作っていきましょうー!
Nextjsでテーマ切り替えを作ってみる
これからはボタンのクリックでテーマ切り替えの実装に入ります。
Emotionのtheming機能についての説明
前のステップではEmotionのThemeProviderとuseThemeを使いましたが、なんとEmotionにはsetThemeみたいな便利な関数がなくて、自前で切り替え機能を作る必要があります。
今回の対策は、contextを使ってthemeをステートとしてグロバールで管理できるように作っていきたいと思います。
Contextを使ってテーマを管理するThemeProviderを作成
store/ThemeContext.tsx
を作成します。
Provider内、toggleThemeとsetupThemeを作ることによってテーマの切り替えや設定する機能を実現します。(正直にいうとやや被る意味ですが…)
"use client"
import { createContext, useCallback, useContext, useMemo, useState } from "react"
import { Theme, defaultTheme, vividTheme } from "../src/theme"
// コンテキストの型
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
setupTheme: (themeName: "default" | "vivid") => void;
}
export const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
type ThemeContextProviderProps = {
children: React.ReactNode
}
export const ThemeProvider = ({ children }: ThemeContextProviderProps) => {
const [theme, setTheme] = useState(defaultTheme)
// ボタンクリックでdefaultとvivideを切り替えできる関数
const toggleTheme = useCallback(() => {
setTheme(currentTheme => currentTheme === defaultTheme ? vividTheme : defaultTheme)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// テーマをsetする関数
const setupTheme = useCallback((themeName: "default" | "vivid") => {
if (themeName === "default") {
setTheme(defaultTheme)
} else {
setTheme(vividTheme)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const value = useMemo(() => ({ theme, toggleTheme, setupTheme }), [setupTheme, theme, toggleTheme])
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)
}
// 基本アプリ内ではuseThemeSwitcherを使ってthemeの呼び出しを行う
export const useThemeSwitcher = (): ThemeContextType => {
const context = useContext(ThemeContext)
if (!context) {
throw new Error("useThemeSwitcher must be used within a ThemeProvider")
}
return context
}
自前のThemeProviderを導入
EmotionのThemeProviderを入れる時と同じところ、src/app/layout.tsx
を直しますに自前のThemeProviderを入れます。
"use client"
import { ThemeProvider as EmotionThemeProvider } from "@emotion/react"
import { Inter } from "next/font/google"
import { ThemeProvider } from "../../store/ThemeContext"
import { defaultTheme } from "../theme"
const inter = Inter({ subsets: ["latin"] })
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<ThemeProvider>
<EmotionThemeProvider theme={defaultTheme}>
<body className={inter.className}>{children}</body>
</EmotionThemeProvider>
</ThemeProvider>
</html>
)
}
ボタンコンポーネント内のthemeの取得方法を変更
前のステップはuseThemeを使いましたが、これからは自前で作ったuseThemeSwitcherからtheme(context)から取るように変更します。他の部分は変更なし。
export const Button = ({
label,
...props
}: ButtonProps) => {
// useThemeからではなく、コンテキストからテーマを取得
const { theme } = useThemeSwitcher()
const style = getStyles(theme)
return (
<button
type="button"
css={style.button}
{...props}
>
{label}
</button >
)
}
ボタンをページに入れてテーマ切り替えの実装
src/app/page.tsx
の変えていきます。
ボタンクリックでtoggleThemeを呼び出し、テーマの切り替えを行います。
また、テーマが変わったら、ページ内表示されているテーマ名も変えたいのでuseEffectを使っています(本題と関係なし)
"use client"
import { css } from "@emotion/react"
import { Button } from "../../components/Button"
import { useThemeSwitcher } from "../../store/ThemeContext"
import { useEffect, useState } from "react"
import { getThemeName } from "../../utils/getThemeName"
export default function Home() {
// ここでテーマと切り替えの関数を取得
const { theme, toggleTheme } = useThemeSwitcher()
// 本題とあまり関係ない部分
const [themeName, setThemeName]= useState<"default" | "vivid">()
const style = getStyles(theme)
useEffect(() => {
setThemeName(getThemeName(theme))
}, [theme])
return (
<main>
<div css={style.container}>
<p>Current Theme: {themeName}</p>
{/* ボタンクリックで切り替え */}
<Button label="Button" onClick={() => toggleTheme()} />
</div>
</main >
)
}
const getStyles = (theme: any) => {
return ({
container: css({
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
minHeight: "600px",
fontSize: "48px",
color: theme.colors.primary.main
})
})
}
別途getThemeName
のヘルパー関数を用意します。
import { Theme } from "@/theme"
export const getThemeName = (theme: Theme) => {
if (theme.colors.primary.main === "#ffcc00") {
return "default"
} else {
return "vivid"
}
}
完成イメージ
簡単ではありますが、テーマの切り替えできました!
自前のThemeProviderをStorybookに導入
さてさて、最後のステップになります!今までThemeを使う際の構成が変わったのでStorybookの方も対応しないといけないです。
decoratorの修正
ここで変わったのは.storybook/preview.tsxのdecoratorであるwithThemeProvider
になります。
ここはStorybookのUIのツールバーのボタンをクリックし、プルダウンからテーマを選択するイメージなので、toggleTheme
ではなくsetupTheme
を使います。
globalTypesのtheme
のvalueが変わったら(テーマが選択されたら)、setupThemeでテーマの切り替えを行うイメージになります。
const withThemeProvider = (Story, context) => {
const theme = getTheme(context.globals.theme);
//
const ThemeSwitcherProvider = ({ children }) => {
const { setupTheme } = useThemeSwitcher()
useEffect(() => {
setupTheme(context.globals.theme);
}, [context.globals.theme, setupTheme])
return <>{children}</>
}
return (
<ThemeProvider>
<EmotionThemeProvider theme={theme}>
<ThemeSwitcherProvider>
<Story {...context} />
</ThemeSwitcherProvider>
</EmotionThemeProvider>
</ThemeProvider>
)
}
完成イメージ
見た目上では前のステップでやったのと同じですが切り替えのやり方が変わりました。
おわりに
今回自前でコンテキストを使って切り替えを実現しました(もはやEmotionのThemeProviderと関係ないのでは?って思います)。
記事色々調べまして、storybookやemotionでtheming機能実現するにはemotion-theming、storybook-addon-emotion-themeなどのパッケージがあったのですが、deprecatedになったり、Storybookのapiが変わったのせいでうまく行かなかったから自分で作ってみました!
おまけ(StorybookでEmotionのスタイルが反映されない時の対策)
@emotion/babel-preset-css-prop
のパッケージをdevDependenciesとしてインストールして、.storybook/main.ts
にbabelの設定を追加すれば解決できます。
(設定しなくてもうまく行けた時があったのでStorybookでEmotionのスタイルが反映されない事象が起きる原因がいまいちわかってない…=-=|||)
(ただし現在は@emotion/babel-preset-css-prop
ではなく**@emotion/babel-plugin**を使うのが推奨されているのようです)
import type { StorybookConfig } from "@storybook/nextjs";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
],
framework: {
name: "@storybook/nextjs",
options: {},
},
docs: {
autodocs: "tag",
},
// Storybookでemotionのスタイルが反映されないの場合、こちらの設定を追加
babel: async (options) => {
options.presets!.push('@emotion/babel-preset-css-prop');
return options;
},
};
export default config;