Next.js 15 & Panda CSS & Park UI & Vitest & Storybookでフロントエンド環境構築

今回は、Next.jsでTailwind CSSではなくPanda CSSというCSS in JSライブラリを使用した環境構築を行いたいと思います。

## リポジトリ

今回の手順で構築した環境+αのリポジトリがこちらです。

https://github.com/airRnot1106/nextjs-pandacss-parkui-vitest-storybook-template

## 手順

:::note info
⭐マークがついている箇所はオプション(筆者の好み)なので飛ばしても問題ありません。
:::

### Next.js

```bash
pnpm dlx create-next-app@latest --use-pnpm
```

以下の質問はすべてデフォルトで問題ありません。

```
✔ What is your project named? … nextjs-pandacss-parkui-vitest-storybook-template
✔ Would you like to use TypeScript with this project? … Yes
✔ Would you like to use ESLint with this project? … Yes
✔ Would you like to use Tailwind CSS with this project? … No
✔ Would you like to use `src/` directory with this project? … Yes
✔ Use App Router (recommended)? … Yes
✔ Would you like to customize the default import alias? … No
```

```bash
cd nextjs-pandacss-parkui-vitest-storybook-template
```

### Biome ⭐

BiomeはRustで書かれた高速なフォーマッタです。

https://biomejs.dev/ja/

```bash
pnpm add --save-dev --save-exact @biomejs/biome
```

```bash
pnpm biome init
```

作成された`biome.json`で好みの設定に変更します。

```diff json:biome.json
{
	"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
	"vcs": {
		"enabled": false,
		"clientKind": "git",
		"useIgnoreFile": false
	},
	"files": {
		"ignoreUnknown": false,
		"ignore": []
	},
	"formatter": {
		"enabled": true,
-		"indentStyle": "tab"
+		"indentStyle": "space"
	},
	"organizeImports": {
		"enabled": true
	},
	"linter": {
		"enabled": true,
		"rules": {
			"recommended": true
		}
	},
	"javascript": {
		"formatter": {
-			"quoteStyle": "double"
+			"indentStyle": "space",
+			"quoteStyle": "single"
		}
	}
}
```

scriptにフォーマットのコマンドを追加しておきます。

```diff json:package.json
{
  "name": "nextjs-pandacss-parkui-vitest-storybook-template",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
-    "lint": "next lint"
+    "lint": "next lint",
+    "check": "biome check --write src/"
  },
  "dependencies": {
    "next": "15.2.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@biomejs/biome": "1.9.4",
    "@eslint/eslintrc": "^3",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "15.2.0",
    "typescript": "^5"
  }
}
```

これで、以下のコマンドでフォーマットができるようになりました。

```bash
pnpm check
```

### Panda CSS

```bash
pnpm install -D @pandacss/dev
```

```bash
pnpm panda init --postcss
```

`panda.config.ts`と`postcss.config.cjs`が生成されていることを確認します。

Panda CSSはビルド時にスタイルを生成するため、`prepare`に`panda codegen`を指定します。

```diff json:package.json
{
  "scripts": {
+    "prepare": "panda codegen",
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  }
}
```

`panda.config.ts`を修正します。

```diff ts:panda.config.ts
import { defineConfig } from "@pandacss/dev";

export default defineConfig({
  // Whether to use css reset
  preflight: true,
+
+  presets: ['@pandacss/preset-base'],
+
  // Where to look for your css declarations
-  include: ["./src/components/**/*.{ts,tsx,js,jsx}", "./src/app/**/*.{ts,tsx,js,jsx}"],
+  include: ["./src/components/**/*.{ts,tsx,js,jsx}", "./src/features/**/*.{ts,tsx,js,jsx}", "./src/app/**/*.{ts,tsx,js,jsx}"], // featuresディレクトリを採用するため追記 ⭐

  // Files to exclude
  exclude: [],

  // Useful for theme customization
  theme: {
    extend: {},
  },

  // The output directory for your css system
  outdir: "styled-system",
});
```

`src/app/globals.css`を修正します。

```diff css:src/app/globals.css
-:root {
-  --background: #ffffff;
-  --foreground: #171717;
-}
-
-@media (prefers-color-scheme: dark) {
-  :root {
-    --background: #0a0a0a;
-    --foreground: #ededed;
-  }
-}
-
-html,
-body {
-  max-width: 100vw;
-  overflow-x: hidden;
-}
-
-body {
-  color: var(--foreground);
-  background: var(--background);
-  font-family: Arial, Helvetica, sans-serif;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
-}
-
-* {
-  box-sizing: border-box;
-  padding: 0;
-  margin: 0;
-}
-
-a {
-  color: inherit;
-  text-decoration: none;
-}
-
-@media (prefers-color-scheme: dark) {
-  html {
-    color-scheme: dark;
-  }
-}
+@layer reset, base, tokens, recipes, utilities;
```

デフォルトのスタイルを削除します。

```diff tsx:src/app/page.tsx
@@ -1,12 +1,10 @@
 import Image from 'next/image';
-import styles from './page.module.css';

 export default function Home() {
   return (
-    <div className={styles.page}>
-      <main className={styles.main}>
+    <div>
+      <main>
         <Image
-          className={styles.logo}
           src="/next.svg"
           alt="Next.js logo"
           width={180}
@@ -20,15 +18,13 @@ export default function Home() {
           <li>Save and see your changes instantly.</li>
         </ol>

-        <div className={styles.ctas}>
+        <div>
           <a
-            className={styles.primary}
             href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
             target="_blank"
             rel="noopener noreferrer"
           >
             <Image
-              className={styles.logo}
               src="/vercel.svg"
               alt="Vercel logomark"
               width={20}
@@ -40,13 +36,12 @@ export default function Home() {
             href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
             target="_blank"
             rel="noopener noreferrer"
-            className={styles.secondary}
           >
             Read our docs
           </a>
         </div>
       </main>
-      <footer className={styles.footer}>
+      <footer>
         <a
           href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
           target="_blank"
```

:::note info
`page.module.css`は不要なので削除してかまいません。
:::

`tsconfig.json`に`baseUrl`を指定します。Panda CSSはスタイルを`styled-system`というディレクトリに生成するため、これにより`import {} from 'styled-system/css'`という書き方でimportができるようになります。

```diff json:tsconfig.json
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
+    "baseUrl": ".",
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}
```

Panda CSS用のESLintプラグインもインストールしておきます。 ⭐

```bash
pnpm add -D @pandacss/eslint-plugin
```

`eslint.config.mjs`を編集します。rulesはお好みで。

```diff js:eslint.config.mjs
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { FlatCompat } from '@eslint/eslintrc';
+import panda from '@pandacss/eslint-plugin';
+import typescriptParser from '@typescript-eslint/parser';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const compat = new FlatCompat({
  baseDirectory: __dirname,
});

const eslintConfig = [
  ...compat.extends('next/core-web-vitals', 'next/typescript'),
+  {
+    files: ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx'],
+    ignores: ['**/*.d.ts', 'styled-system'],
+    plugins: {
+      '@pandacss': panda,
+    },
+    languageOptions: {
+      parser: typescriptParser,
+    },
+    rules: {
+      // Configure rules here
+      '@pandacss/prefer-longhand-properties': 'error',
+      // You can also use the recommended rules
+      ...panda.configs.recommended.rules,
+      // Or all rules
+      // ...panda.configs.all.rules,
+    },
+  },
];

export default eslintConfig;
```

スタイルのthemeを作成するために、カラーコードからcolor shadesをいい感じに作成してくれる`theme-colors`をインストールします。 ⭐

```bash
pnpm install theme-colors
```

`panda.config.ts`を編集します。今回は[Rosé Pine](https://rosepinetheme.com/)というPaletteを使用しています。

```ts:panda.config.ts
import {
  type SemanticTokens,
  type Tokens,
  defineConfig,
  defineSemanticTokens,
  defineTokens,
} from '@pandacss/dev';
import { getColors } from 'theme-colors';

type ColorPalette = {
  name: string;
  tokens: NonNullable<Tokens['colors']>;
  semanticTokens: NonNullable<SemanticTokens['colors']>;
};

const rosePineDawn = {
  base: '#faf4ed',
  surface: '#fffaf3',
  overlay: '#f2e9e1',
  muted: '#9893a5',
  subtle: '#797593',
  text: '#575279',
  love: '#b4637a',
  gold: '#ea9d34',
  rose: '#d7827e',
  pine: '#286983',
  foam: '#56949f',
  iris: '#907aa9',
  'highlight-low': '#f4ede8',
  'highlight-med': '#dfdad9',
  'highlight-high': '#cecacd',
} as const satisfies Record<string, string>;

const rosePineMoon = {
  base: '#232136',
  surface: '#2a273f',
  overlay: '#393552',
  muted: '#6e6a86',
  subtle: '#908caa',
  text: '#e0def4',
  love: '#eb6f92',
  gold: '#f6c177',
  rose: '#ea9a97',
  pine: '#3e8fb0',
  foam: '#9ccfd8',
  iris: '#c4a7e7',
  'highlight-low': '#2a283e',
  'highlight-med': '#44415a',
  'highlight-high': '#56526e',
} as const satisfies Record<string, string>;

const defineColorPalette = (
  name: string,
  light: string,
  dark: string,
): ColorPalette => {
  const lightThemeColors = getColors(light);
  const darkThemeColors = getColors(dark);
  return {
    name,
    tokens: defineTokens.colors({
      light: Object.keys(lightThemeColors).reduce(
        (acc, key) => {
          acc[key] = {
            value: lightThemeColors[key] as string,
          };
          return acc;
        },
        {} as Record<string, { value: string }>,
      ),
      dark: Object.keys(darkThemeColors).reduce(
        (acc, key) => {
          acc[key] = {
            value: darkThemeColors[key] as string,
          };
          return acc;
        },
        {} as Record<string, { value: string }>,
      ),
    }),
    semanticTokens: defineSemanticTokens.colors(
      Object.keys(lightThemeColors).reduce(
        (acc, key) => {
          acc[key] = {
            value: {
              base: `{colors.${name}.light.${key}}`,
              _osDark: `{colors.${name}.dark.${key}}`,
            },
          };
          return acc;
        },
        {} as Record<string, { value: { base: string; _osDark: string } }>,
      ),
    ),
  };
};

const defineColorPalettes = <T extends Record<string, string>>(
  lightPalette: T,
  darkPalette: Record<keyof T, string>,
): Record<keyof T, ColorPalette> =>
  Object.keys(lightPalette).reduce(
    (acc, key) => {
      acc[key as keyof T] = defineColorPalette(
        key,
        lightPalette[key] as string,
        darkPalette[key] as string,
      );
      return acc;
    },
    {} as Record<keyof T, ColorPalette>,
  );

const colorPalettes = defineColorPalettes(rosePineDawn, rosePineMoon);

export default defineConfig({
  // Whether to use css reset
  preflight: true,

  presets: ['@pandacss/preset-base'],

  // Where to look for your css declarations
  include: [
    './src/components/**/*.{ts,tsx,js,jsx}',
    './src/features/**/*.{ts,tsx,js,jsx}',
    './src/app/**/*.{ts,tsx,js,jsx}',
  ],

  // Files to exclude
  exclude: [],

  // Useful for theme customization
  theme: {
    extend: {
      tokens: {
        fontSizes: {
          xs: { value: '0.75rem' },
          sm: { value: '0.875rem' },
          md: { value: '1rem' },
          lg: { value: '1.125rem' },
          xl: { value: '1.25rem' },
          '2xl': { value: '1.5rem' },
          '3xl': { value: '1.875rem' },
          '4xl': { value: '2.25rem' },
          '5xl': { value: '3rem' },
          '6xl': { value: '4rem' },
          '7xl': { value: '5rem' },
          '8xl': { value: '6rem' },
        },
        colors: Object.values(colorPalettes).reduce(
          (acc, palette) => {
            acc[palette.name] = palette.tokens;
            return acc;
          },
          {} as Record<string, ColorPalette['tokens']>,
        ),
      },
      semanticTokens: {
        colors: Object.values(colorPalettes).reduce(
          (acc, palette) => {
            acc[palette.name] = palette.semanticTokens;
            return acc;
          },
          {} as Record<string, ColorPalette['semanticTokens']>,
        ),
      },
    },
  },

  // The output directory for your css system
  outdir: 'styled-system',

  jsxFramework: 'react',
});
```

これでlightとdarkのthemeを簡単に設定できるようになりました。

### @tsconfig/strictest ⭐

とりあえず型を厳しくしておきます。

```bash
pnpm add -D @tsconfig/strictest
```

```diff json:tsconfig.json
{
+  "extends": [
+    "@tsconfig/strictest/tsconfig.json"
+  ],
  "compilerOptions": {
    "target": "ES2017",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "baseUrl": ".",
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}
```

### Park UI

Park UIはHeadless コンポーネントUIライブラリであるArk UIをPanda CSSでスタイリングしたライブラリです。自分でデザインできる人はArk UIでも大丈夫です。

```bash
pnpm add @ark-ui/react
```

```bash
pnpm add -D @park-ui/panda-preset
```

`panda.config.ts`を編集します。

```diff ts:panda.config.ts
@@ -5,6 +5,9 @@ import {
   defineSemanticTokens,
   defineTokens,
 } from '@pandacss/dev';
+import { createPreset } from '@park-ui/panda-preset';
+import amber from '@park-ui/panda-preset/colors/amber';
+import sand from '@park-ui/panda-preset/colors/sand';
 import { getColors } from 'theme-colors';

 type ColorPalette = {
@@ -117,7 +120,10 @@ export default defineConfig({
   // Whether to use css reset
   preflight: true,

-  presets: ['@pandacss/preset-base'],
+  presets: [
+    '@pandacss/preset-base',
+    createPreset({ accentColor: amber, grayColor: sand, radius: 'sm' }),
+  ],

   // Where to look for your css declarations
   include: [
```

ルートに`park-ui.json`を作成します。ここでは使用するフレームワークやコンポーネントのディレクトリを指定します。

```json:park-ui.json
{
  "$schema": "https://park-ui.com/registry/latest/schema.json",
  "jsFramework": "react",
  "outputPath": "./src/components/ui"
}
```

あとは

```bash
npx @park-ui/cli components add button
```

のような形でコマンドを実行すればPark UIのコンポーネントを追加できます。shadcn/uiのような感じですね。

### Storybook

Storybook v8.3でVitestをテストランナーとして使用できるようになりました。便利ですね。

```bash
pnpm create storybook@latest
```

scriptに以下のものを追加します。

```diff json:package.json
{
    "prepare": "panda codegen",
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
-    "check": "biome check --write src/"
+    "check": "biome check --write src/",
+    "storybook": "storybook dev -p 6006",
+    "build-storybook": "storybook build",
+    "test": "vitest"
}
```

Panda CSSを使用しているため`.storybook/preview.ts`でcssファイルを読み込みます。

```diff ts:.storybook/preview.ts
+import '../src/app/globals.css';

import type { Preview } from '@storybook/react';
```

アクセシビリティ用のaddonもインストールしておきましょう。 ⭐

```bash
pnpm add -D @storybook/addon-a11y
```

```diff ts:.storybook/main.ts
import type { StorybookConfig } from '@storybook/experimental-nextjs-vite';

const config: StorybookConfig = {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-onboarding',
    '@chromatic-com/storybook',
    '@storybook/experimental-addon-test',
+    '@storybook/addon-a11y',
  ],
  framework: {
    name: '@storybook/experimental-nextjs-vite',
    options: {},
  },
  staticDirs: ['../public'],
};
export default config;
```

### Vitest

Storybookのインストール時にVitestもインストールされているのですが、足りないものもあるのでインストールしておきます。Storyのテストで完結するならもしかしたら要らないかも？

```bash
pnpm add -D @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom vite-tsconfig-paths
```

```ts:vitest.config.mts
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { storybookTest } from '@storybook/experimental-addon-test/vitest-plugin';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';

const dirname =
  typeof __dirname !== 'undefined'
    ? __dirname
    : path.dirname(fileURLToPath(import.meta.url));

// More info at: https://storybook.js.org/docs/writing-tests/test-addon
export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    environment: 'jsdom',
    workspace: [
      {
        extends: true,
        plugins: [
          // The plugin will run tests for the stories defined in your Storybook config
          // See options at: https://storybook.js.org/docs/writing-tests/test-addon#storybooktest
          storybookTest({ configDir: path.join(dirname, '.storybook') }),
        ],
        test: {
          name: 'storybook',
          browser: {
            enabled: true,
            headless: true,
            name: 'chromium',
            provider: 'playwright',
          },
          setupFiles: ['.storybook/vitest.setup.ts'],
        },
      },
    ],
  },
});
```

Vitest用のESLintプラグインもインストールしておきます。 ⭐

```bash
pnpm add -D @vitest/eslint-plugin
```

```js:eslint.config.mjs
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { FlatCompat } from '@eslint/eslintrc';
import panda from '@pandacss/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import vitest from '@vitest/eslint-plugin';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const compat = new FlatCompat({
  baseDirectory: __dirname,
});

const eslintConfig = [
  ...compat.extends('next/core-web-vitals', 'next/typescript'),
  {
    files: ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx'],
    ignores: ['**/*.d.ts', 'styled-system'],
    plugins: {
      '@pandacss': panda,
    },
    languageOptions: {
      parser: typescriptParser,
    },
    rules: {
      // Configure rules here
      '@pandacss/prefer-longhand-properties': 'error',
      // You can also use the recommended rules
      ...panda.configs.recommended.rules,
      // Or all rules
      // ...panda.configs.all.rules,
    },
  },
  {
    files: ['*.{spec,test}.{js,jsx,ts,tsx}'], // or any other pattern
    plugins: {
      vitest,
    },
    rules: {
      ...vitest.configs.recommended.rules, // you can also use vitest.configs.all.rules to enable all rules
      'vitest/consistent-test-filename': 'error',
      'vitest/consistent-test-it': ['error', { fn: 'test' }],
      'vitest/no-conditional-expect': 'error',
      'vitest/no-conditional-in-test': 'error',
      'vitest/no-conditional-tests': 'error',
      'vitest/no-disabled-tests': 'warn',
      'vitest/no-duplicate-hooks': 'error',
      'vitest/no-focused-tests': 'warn',
      'vitest/no-standalone-expect': 'error',
      'vitest/no-test-prefixes': 'error',
      'vitest/no-test-return-statement': 'error',
      'vitest/padding-around-after-all-blocks': 'error',
      'vitest/padding-around-after-each-blocks': 'error',
      'vitest/padding-around-all': 'error',
      'vitest/padding-around-before-all-blocks': 'error',
      'vitest/padding-around-before-each-blocks': 'error',
      'vitest/padding-around-describe-blocks': 'error',
      'vitest/padding-around-expect-groups': 'error',
      'vitest/padding-around-test-blocks': 'error',
      'vitest/prefer-called-with': 'error',
      'vitest/prefer-comparison-matcher': 'error',
      'vitest/prefer-each': 'error',
      'vitest/prefer-equality-matcher': 'error',
      'vitest/prefer-expect-assertions': 'error',
      'vitest/prefer-expect-resolves': 'error',
      'vitest/prefer-hooks-in-order': 'error',
      'vitest/prefer-hooks-on-top': 'error',
      'vitest/prefer-lowercase-title': 'error',
      'vitest/prefer-mock-promise-shorthand': 'error',
      'vitest/prefer-snapshot-hint': 'error',
      'vitest/prefer-spy-on': 'error',
      'vitest/prefer-strict-equal': 'error',
      'vitest/prefer-to-be-object': 'error',
      'vitest/prefer-to-be': 'error',
      'vitest/prefer-to-contain': 'error',
      'vitest/prefer-to-have-length': 'error',
      'vitest/prefer-todo': 'error',
      'vitest/prefer-vi-mocked': 'error',
      'vitest/require-hook': 'error',
      'vitest/valid-describe-callback': 'error',
      'vitest/valid-expect-in-promise': 'error',
      'vitest/valid-expect': 'error',
    },
  },
];

export default eslintConfig;
```

## おわりに

久しぶりにがっつり環境構築しました。
筆者はTailwind CSSでCSSに入門したのですが、Panda CSSの方が好みかもしれないです。なによりもGrid Layoutが書きやすい。