🐼

PandaCSSのConfig Recipeでスタイル管理する

2024/09/26に公開

モチベーション

Figma にあるデザインを忠実に再現し、かつ効果的にスタイルを管理したいですよね。
例えば Badge のコンポーネントがあるとします。


https://www.pandamastery.com

statuskind という2つのパラメータがあり、合計8種類のスタイルがあります。

export type Status = 'neutral' | 'info' | 'success' | 'error'
export type Kind = 'solid' | 'outline'

React + CSS Modules で素直に作るのであれば次のように実装します。

.base {
  color: white;
  font-size: 21px;
  font-weight: bold;
  text-transform: uppercase;
  border-radius: 4px;
  line-height: 1.33;
  padding-left: 8px;
  padding-right: 8px;
}

.neutral {
  &.solid {
    background: var(--colors-neutral-500);
  }
  &.outline {
    border-width: 2px;
    border-color: var(--colors-neutral-400);
    color: var(--colors-neutral-500);
  }
}

// info, success, error も同様に…
import styles from './styles.module.scss'

export function Badge(p: PropsWithChildren<{
  status: Status
  kind: Kind
}>) {
  return (
    <div className={`${styles.base} ${styles[p.status]} ${styles[p.kind] ?? ''}`}>
      {p.children}
    </div>
  )
}

Badge コンポーネントを作るという目的は達成できますが、保守しやすくするためにはどうすればいいでしょうか?
Status, Kind は Badge 以外にも使用する可能性があります。
例えば登録結果をモーダルに出すとき success, error というキーワードを使いアイコンの振り分けや色を取得したくなりますね。

新たに warning という Status が追加される可能性もあります。
Badge だけなら問題ありませんが Button や TextField などにも対応したい場合、やることが増えてしまいますね。
削除、変更があった場合はもっとややこしくなります……

色はどうでしょうか?
単純な DOM へのスタイリングだけであれば scss(もしくはcss) の変数に定義することでも問題ありませんが WebGL へ色を渡したいときどうすればいいでしょうか?

他にも Breakpoint のしきい値を TypeScript 側で使用したいケースも考えられます。

そうなると scss で管理するより TypeScript 側で管理したくなります。
そこで Panda CSS を触ってみることにしました。

Panda CSS

React のコンポーネント chakra を作っている人たちが開発されている CSS-in-JS です。

TypeScript で書けるので (s)css <-> TS という言語をまたいだスタイル定義の共有問題も解決できます。
デザイントークンも定義できるため柔軟なスタイル管理ができます。
ビルド時にスタイル生成されるところがとても嬉しいです。

教材

Panda CSS はドキュメントがとても丁寧ですが、具体的な参考事例が少なく調査に時間がかかりそうでした。
そこで panda mastery を購入しました。

70以上の動画とその解説があります。
repo と Figma は制限なく公開されています。

動画は英語で翻訳機能はありませんでした。
自分も英語はよくわかっていませんが、サンプルコードと動画内容+字幕を見ることで、進めることができました。

149ドルと少しお高めですが、遥か高みにいるエンジニアの思想にふれることができて、とても勉強になりました。

作ったもの

Next.js + Panda CSS で簡単なスタイル管理を行いました。
Status ごとにボタンを用意し、選択されるごとにスタイルを変更します。

環境構築

Next.js の初期化後 Panda CSS の設定を行います。
PandaCSS のレッスンで pnpm を使用していたため pnpm で管理することにしました。

mkdir love-panda && cd love-panda
npx create-next-app@latest . --use-pnpm

pnpm install -D @pandacss/dev
pnpm panda init --postcss

Atomic Recipe vs Config Recipe

Panda CSS のスタイリングには大きく分けて Atomic Recipe と Config Recipe があります。

Atomic Recipe

csscva, sva 関数を使いスタイリングします。

import { css, cva, sva } from '~/styled-system/css'

export const c = cva({
  background: 'red.100',
})
export const recipe = sva({
  slots: ['root', 'icon', 'title'],
  base: { /* */ },
  icon: { /* */ },
  title: { /* */ },
})

export function Component() {
  const r = recipe('waring')
  return (
    <main className={css({
      minW: 'full',
    })}>
      <div className={r.root}>
        <svg className={r.icon} />
        <h2 className={r.title}>title</h2>
      </div>
      <div className={c}>
        // ...
      </div>
    </main>
  )
}
  • css は要素に直接スタイリングするときに使用
  • cva は単純な要素にスタイルを当てるときに使用
  • sva は要素の組み合わせがある場合に使用

これらは Panda CSS が用意しているものを使うので手早く実装できます。

Config Recipe

設定ファイルに独自のスタイルを定義しビルドすることでプロジェクト全体でその定義を使用することができます。
cva, sva でも同じことができますが、設定ファイルに定義することでより高レイヤーの管理ができます。
例えば monorepo による複数のプロジェクトを管理するなどが考えられます。

今回の記事ではこの Config Recipe を基盤にして考えています。

defineRecipe

defineRecipe を使用してスタイルを定義します。

import { defineRecipe } from '@pandacss/dev'

export const badgeRecipe = defineRecipe({
  // ...
})
全体はこちら
import { defineRecipe } from '@pandacss/dev'

export const badgeRecipe = defineRecipe({
  className: 'badge',
  base: {
    color: 'white',
    fontSize: '21px',
    fontWeight: 'bold',
    textTransform: 'uppercase',
    borderRadius: '4px',
    lineHeight: '1.33',
    px: '8px',
  },
  variants: {
    status: {
      neutral: {
        colorPalette: 'gray',
      },
      info: {
        colorPalette: 'blue',
      },
      success: {
        colorPalette: 'green',
      },
      error: {
        colorPalette: 'red',
      },
    },
    kind: {
      solid: {
        bg: 'colorPalette.500',
      },
      outline: {
        borderWidth: '2px',
        borderColor: 'colorPalette.400',
        color: 'colorPalette.500',
      },
    },
  },
  defaultVariants: {
    status: 'info',
    kind: 'outline',
  },
})

それを panda.config.ts に登録します。

import { defineConfig } from '@pandacss/dev'
import { badgeRecipe } from './badge.recipe'

export default defineConfig({
  preflight: true,
  include: ['./**/*.{ts,tsx}'],
  exclude: [],
  theme: {
    extend: {
      recipes: {
        badgeRecipe,
      },
    },
  },
  outdir: 'styled-system',
  jsxFramework: 'react',
})

あとは npx panda codegen でコードを生成すれば使えるようになります。

'use client'

import { useCallback, useState } from 'react'
import { css, cx } from '~/styled-system/css'
import { Flex } from '~/styled-system/jsx'
import { badgeRecipe, BadgeRecipeVariant } from '~/styled-system/recipes'

type Status = BadgeRecipeVariant['status']

const statuses: Status[] = ['neutral', 'info', 'success', 'error']

export default function Home() {
  const [current, setCurrent] = useState<Status>('neutral')
  const updateAs = useCallback(
    (status: Status) => () => setCurrent(status),
    [setCurrent],
  )

  return (
    <main className={css({
      py: 10,
    })}>
      <Flex width='full' justifyContent='center' gap={2}>
        {statuses.map(status => (
          <button
            key={status}
            onClick={updateAs(status)}
            className={cx(
              badgeRecipe({
                status: status,
                kind: current === status ? 'solid' : 'outline',
              }),
              css({
                cursor: 'pointer',
              }),
            )}
          >
            {status}
          </button>
        ))}
      </Flex>
    </main>
  )
}

おわりに

Panda CSS を使用することで柔軟なスタイル管理ができそうです。
今回は簡単な例だけでしたが monorepo を用いたプロジェクトをまたぐスタイル管理も試してみたいです。

株式会社blue TechBlog

Discussion