今回は、Next.jsでTailwind CSSではなくPanda CSSというCSS in JSライブラリを使用した環境構築を行いたいと思います。
リポジトリ
今回の手順で構築した環境+αのリポジトリがこちらです。
手順
⭐マークがついている箇所はオプション(筆者の好み)なので飛ばしても問題ありません。
Next.js
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
cd nextjs-pandacss-parkui-vitest-storybook-template
Biome ⭐
BiomeはRustで書かれた高速なフォーマッタです。
pnpm add --save-dev --save-exact @biomejs/biome
pnpm biome init
作成された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にフォーマットのコマンドを追加しておきます。
{
"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"
}
}
これで、以下のコマンドでフォーマットができるようになりました。
pnpm check
Panda CSS
pnpm install -D @pandacss/dev
pnpm panda init --postcss
panda.config.ts
とpostcss.config.cjs
が生成されていることを確認します。
Panda CSSはビルド時にスタイルを生成するため、prepare
にpanda codegen
を指定します。
{
"scripts": {
+ "prepare": "panda codegen",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
}
}
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
を修正します。
-: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;
デフォルトのスタイルを削除します。
@@ -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"
page.module.css
は不要なので削除してかまいません。
tsconfig.json
にbaseUrl
を指定します。Panda CSSはスタイルをstyled-system
というディレクトリに生成するため、これによりimport {} from 'styled-system/css'
という書き方でimportができるようになります。
{
"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プラグインもインストールしておきます。 ⭐
pnpm add -D @pandacss/eslint-plugin
eslint.config.mjs
を編集します。rulesはお好みで。
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
をインストールします。 ⭐
pnpm install theme-colors
panda.config.ts
を編集します。今回はRosé PineというPaletteを使用しています。
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 ⭐
とりあえず型を厳しくしておきます。
pnpm add -D @tsconfig/strictest
{
+ "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でも大丈夫です。
pnpm add @ark-ui/react
pnpm add -D @park-ui/panda-preset
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
を作成します。ここでは使用するフレームワークやコンポーネントのディレクトリを指定します。
{
"$schema": "https://park-ui.com/registry/latest/schema.json",
"jsFramework": "react",
"outputPath": "./src/components/ui"
}
あとは
npx @park-ui/cli components add button
のような形でコマンドを実行すればPark UIのコンポーネントを追加できます。shadcn/uiのような感じですね。
Storybook
Storybook v8.3でVitestをテストランナーとして使用できるようになりました。便利ですね。
pnpm create storybook@latest
scriptに以下のものを追加します。
{
"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ファイルを読み込みます。
+import '../src/app/globals.css';
import type { Preview } from '@storybook/react';
アクセシビリティ用のaddonもインストールしておきましょう。 ⭐
pnpm add -D @storybook/addon-a11y
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のテストで完結するならもしかしたら要らないかも?
pnpm add -D @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom vite-tsconfig-paths
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プラグインもインストールしておきます。 ⭐
pnpm add -D @vitest/eslint-plugin
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が書きやすい。