next/font/google で読み込んだフォントを Storybook にも反映させるのに苦戦した話

ちゃんと考えれば分かる話なのですが、なんで？と少し詰まってしまったので備忘録として残しておきます。

# 状況

今まで Link タグで記述していた Google Font の読み込みを Next.js の `next/font/google` を使用するように変更することにしました。

https://nextjs.org/docs/app/building-your-application/optimizing/fonts#google-fonts

各種バージョンは以下の通りです。

```json
{
  "next": "13.5.6",
  "storybook": "^7.5.1"
}
```

_今回の話ではあまり関係ないですが、Next.js は App Router を使用しています。_

## link タグで Google Font を読み込んでいた時の各種ファイルの状態

CSS Variable として font family を定義したものを用意して、それを body の style として当てていました。

```css:src/styles/global.css
:root {
  /* Google Font の Noto Sans JP を CSS Variables として定義 */
  --font: "Noto Sans JP", sans-serif;
}

body {
  /* body の font-family に変数を当てていた */
  font-family: var(--font);
}
```

Next.js の layout.tsx や Storybook の preview.tsx で、
上記 css ファイルを import し、 link タグで Google Font を読み込んでいました。

```tsx:src/app/layout.tsx
import "styles/global.css"

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html>
      <head>
        <link
          href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap"
          rel="stylesheet"
        />
      </head>
      <body>{children}</body>
    </html>
  )
}
```

```tsx:.storybook/preview.tsx
import "styles/global.scss";
import React from "react";
import type { Preview } from "@storybook/react";

const preview: Preview = {
  // decorator 部分のみ抜粋
  decorators: [
    (Story) => (
      <>
        <link
          href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap"
          rel="stylesheet"
        />
        <Story />
      </>
    ),
  ],
};
export default preview;
```

# link タグを消して next/font/google に書き換える

Next.js 側と Storybook 側とで読み込む必要があるので別途ファイルを用意しフォントを用意します。

CSS Variable として使用したいので variable も指定します。
_引数の詳細は Next.js のドキュメントをご確認ください。_

https://nextjs.org/docs/app/api-reference/components/font#font-function-arguments

```ts:src/font.ts
import { Noto_Sans_JP } from "next/font/google";

export const NotoSansJP = Noto_Sans_JP({
  subsets: ["latin"],
  weight: ["400",  "700"],
  display: "swap",
  variable: "--font-noto-sans-jp",
  fallback: ["sans-serif"],
});
```

フォントの準備ができたので各 tsx ファイルで指定している link タグを先ほど用意したものに書き換えます。

```diff:src/app/layout.tsx
import "styles/global.css"
+ import { NotoSansJP } from "font.ts"

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html>
-      <head>
-        <link
-          href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap"
-          rel="stylesheet"
-        />
-      </head>
+      <body className={NotoSansJP.variable}>{children}</body>
    </html>
  )
}
```

```diff:.storybook/preview.tsx
import "styles/global.scss";
import React from "react";
import type { Preview } from "@storybook/react";
+ import { NotoSansJP } from "../src/font.ts"

const preview: Preview = {
  // decorator 部分のみ抜粋
  decorators: [
    (Story) => (
+      <div className={NotoSansJP.variable}>
-        <link
-          href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap"
-          rel="stylesheet"
-        />
        <Story />
+      </div>
    ),
  ],
};
export default preview;
```

そして CSS ファイルの方も font.ts の方で指定した variable の値に書き換えました。

```diff:src/styles/global.css
:root {
-  --font: "Noto Sans JP", sans-serif;
}

body {
+  font-family: var(--font-noto-sans-jp);
}
```

## 書き換えた結果

Next.js の dev server の方では CSS Variable に値が入っており、実際にフォントが反映されている事が確認できました。

![CSS Variable に値が入っている](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584802/6544f25b-0865-b5d6-7d67-5fa1948d6e10.png)


しかし、Storybook を立ち上げるとフォントが当たっていません。
インスペクタで確認してみても CSS Variable に値が入っていません。

![Storybook でフォントが反映されない](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584802/caf936ee-bd6b-4c8d-f82a-b7c629dfe27c.png)

## なぜ Storybook の方では値が適切に設定されなかったのか

よく考えればわかることです。

global.css で私は **body タグに font-family を設定していました**。
しかし、 preview.tsx で Story の wrapper として **div タグを用意し、それに NotoSansJP.variable を指定していました**。

Storybook の Story は Storybook が用意した div の中に Story (decorator) がレンダリングされます。

CSS Variable は指定した要素とその子要素で有効になります。
つまり、body で指定している CSS Variable は その段階では未定義なのです。

かなり簡易的ですが、以下のような感じです。

```html
<body class="sb-main-padded sb-show-main">
  <!-- この要素では --font-noto-sans-jp は未定義のためここで使用しても何も起こらない -->
  <!-- Storybook が用意している div がいくつかある  -->
  <div class="sb-xxx">...</div>
  <div class="sb-xxx">...</div>
  <div id="storybook-root">
      <!-- preview.tsx で定義した decorator -->
      <div class="__variable_noto-sans-jp-normal">
        <!-- ここの要素から --font-noto-sans-jp が定義されるので、これ以降で使用する必要がある -->
        Story がレンダリングされる
      </div>
    </div>
  </div>
</body>
```

## 解決策: body で font-family を指定するのをやめる

storybook で body に対して className を渡す方法がありません。
そのため body タグで font-family を指定するのをやめ、class を別途用意することにしました

```diff:src/styles/global.css
- body {
+ .base {
  font-family: var(--font-noto-sans-jp);
}
```

layout.tsx や preview.tsx でこの class を当てるようにします。

```diff:src/app/layout.tsx
import "styles/global.css"
import { NotoSansJP } from "font.ts"

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html>
+      <body className={["base", NotoSansJP.variable].join(" ")}>{children}</body>
    </html>
  )
}
```

```diff:.storybook/preview.tsx
import "styles/global.scss";
import React from "react";
import type { Preview } from "@storybook/react";
import { NotoSansJP } from "../src/font.ts"

const preview: Preview = {
  // decorator 部分のみ抜粋
  decorators: [
    (Story) => (
+      <div className={["base", NotoSansJP.variable].join(" ")}>
        <Story />
      </div>
    ),
  ],
};
export default preview;
```

これでフォントの CSS Variable の定義と使用するタイミングが同じになり、Storybook でもフォントが反映されるようになりました 🎉

![Storybook でもフォントが反映された](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584802/fcee630d-f7ce-35b6-6cf0-6a93ca1916d8.png)


# おわりに

CSS Variable の仕様をきちんと理解していれば悩むこともない些細な事でしたが、
普段頻繁に使わないことや Next.js の font のバグでそもそも動作しないという過去もあったことから、ここは間違っていないだろうという思い込みもあり小一時間悩んでしまいました。
こういう仕様を考えれば当たり前なことはなかなか検索してもヒットしないので私みたいな方（いるのかそんな人）の参考になれば幸いです。
先入観にとらわれて解決方法を見失ってしまうことが多々あるので気をつけていきたいところです。こういう当たり前なことは一旦別のことをして頭をリフレッシュすると案外サクッと解決したりするものですが、アウトプットすることで同じ様な過ちを繰り返さないよう自戒も込めて。。