一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

Panda CSS でデザインシステムのメンタルモデルを確立する

この記事は一休.com Advent Calendar 2025の2日目の記事です。

レストランプロダクト UI 開発チームの鍛治です。一休.com レストランのフロントエンドを担当しています。

2025 年 4 月、PayPay グルメ の全面リニューアルが完了しました。このリニューアルでは「一休.com レストラン」と「PayPay グルメ」の 2 つのサービスを 1 つのコードベースに統合しています。

一休レストラン・PayPay グルメではリニューアルプロジェクトを契機に Tailwind CSS から Panda CSS への置き換えを進めています。 また置き換えやってるのか 1 と思われるかもしれませんが、もちろん理由あっての導入です。本稿では、なぜ導入したのか、それにより何が得られたのかをご紹介したいと思います。

PayPayグルメについて

本記事で登場する「PayPayグルメ」について簡単に説明します。

PayPayグルメは「PayPay株式会社の協力のもとLINEヤフー株式会社が運営しているサービス」2です。カジュアルなレストランや居酒屋に加えてカラオケまで、多彩な店舗ラインアップを揃えた飲食店予約サービスです。

PayPayグルメトップ

2025年3月末日まではLINEヤフーが開発/運用の両方を担っていました。その後、2025年4月からは一休に開発/運用が委託されています。主な理由としては、一休がLINEヤフーのグループ会社であり、一休が培ってきた飲食店予約事業の知見を活用しつつグループ内の運用を一本化するためです。

PayPay グルメと一休レストランの統合

PayPay グルメを一休に移管するにあたっては、現行の一休.comレストランのシステムにPayPayグルメを統合するという選択肢をとりました。つまり、アプリケーションのリポジトリを一休.comレストランと PayPay グルメで共通化し、2つのサイトを配信できるような仕組みを作ることにしました。

さらに、統合にあたってPayPayグルメのUIを全面リニューアルすることにしました。具体的には、一休.comレストランの UI をもとにしつつ、新たに PayPay グルメのブランドイメージを表現したデザインを適用しました。

統合にあたり、まずは1つのリポジトリで2つのサイトを運用するために解決した課題をいくつか紹介します。

サイト固有のコードをどうするか

1つのコードベースで2サイトを実現するにあたって、次のような課題がありました。

1つ目の課題は、フロントエンドのバンドルサイズ問題です。一方のサイトにしか使わないライブラリがもう一方のサイトのプロダクションビルドに含まれてしまうと、ネットワーク帯域や計算資源を無駄に消費してしまいます。例えば、PayPay グルメでのみ利用している地図サービス MapBox のクライアントライブラリが挙げられます

2つ目の課題がより重要です。プロジェクト開始時点では PayPay グルメのリニューアルは公表されていませんでした。バンドルサイズに目を瞑ったとしても、一休.comレストランのプロダクションビルドに PayPay グルメを推測されるようなコードを含めるわけにはいきません。

上記の課題を解決するために、ビルド時に Vite の環境変数を切り替えることで、本番で各サイトのコードに他方のサイトだけでしか使わないコードが入り込まないようにしました。

各サイト固有のデザインの適用

一休レストランのみ開発している時は特に意識しませんでしたが、2 サイトで使用する色が異なります。

最初期のプロトタイプでは、一休.comレストランのためのデザイントークンの値を直接書き換えて、PayPay グルメのルック&フィールを検証していました。

const ikyu = process.env.VITE_MODE !== 'ppg'

module.exports = {
  theme: {
    extend: {
      colors: {
        accent: {
          // PayPay グルメでは "pink" だがカラーはブルー
          pink:  ikyu ? '#ff4d4d' : '#3895ff',
          brown: ikyu ? '#af9b65' : '#3895ff',
          khaki: ikyu ? '#c0b28b' : '#3895ff',
          beige: ikyu ? '#f8f6f1' : '#E5F1FF',
          blue:  ikyu ? '#397bbe' : '#3895ff',
        },
      },
    },
  },
}

当時のデザイントークンは上記の通り意味と色の名前が混在していて、プロトタイプ時点のコードでは accent-pink なのに表示される色は青という状況でした。 例えば、以下のような実装では問題が発生します。

export function ReserveButton() {
  return (
    // PayPay グルメではブルーになる
    <button className="accent-pink">
      空席確認・予約
    </button>
  )
}

このコードではクラス名が accent-pink ですが、PayPayグルメ では実際にはブルー #3895ff が表示されます。 このコードは実際に以下のような画面が表示されています。

一休 PayPay グルメ
一休レストラン予約ボタン PayPayグルメ予約ボタン

コード上の色名と実際の色が一致しないため、メンテナンス時に混乱を招く原因となっていました。 色そのものの名前 (primitive) と、それをどういう場面でどういう効果を与えるために使うのかという意味 (semantic) を峻別することの重要性に気付かされた瞬間でした。

さきほどの例を、primitive token と semantic token を分けると以下のようになります。

const ikyu = process.env.VITE_MODE !== 'ppg'

// primitive token
const ikyuPink = '#ff4d4d'
const ppgBlue = '#3895ff'

module.exports = {
  theme: {
    extend: {
      colors: {
        button: {
          // sematic token
          primary:  ikyu ? ikyuPink : ppgBlue,
        },
      },
    },
  },
}
export function ReserveButton() {
  return (
    <button className="button-primary">
      クーポンを獲得する
    </button>
  )
}

こうした反省を踏まえ、デザイントークンをあらためて見直すことになりました。 ここまでお伝えしてきたように、値そのものの名前である primitive token とそれに対する意味付けである semantic token をちゃんと認識し、峻別していく、という整理です。

この過程で Panda CSS の採用を決定しました。 Panda CSS では core token と semantic token を分けて定義する形になっており、色と意味の峻別という私たちがあらたに得たメンタルモデルを後押ししてくれるからです。

もともと Panda CSS に注目していたしていたことも追い風となり、リニューアルで大規模刷新する今こそ、移行コストを一度に払おうという結論に至りました。

本記事でのトークンについて

ここで用語の整理をさせてください。 デザイントークンの見直しの中で、一休のデザインシステムでは、ここまで述べてきたように primitive token と semantic token という整理を行いました。 一方、Panda CSS では core token / semantic token という用語が使われています。

トークンの種類 一休デザインシステム Panda CSS
値そのもののトークン primitive token core token
意味を持つトークン semantic token semantic token

以降、一休デザインシステム文脈では primitive token 、Panda CSS の文脈では core token と呼びます。

Panda CSS

Panda CSS は型安全かつゼロランタイムで使える CSS-in-JS のツールです。 Chakra UI の開発チームが「Chakra の設計思想をライブラリ非依存で使い回せるように」と開発しており、最終的には純粋な CSS ファイルを出力するため実行時コストはゼロになります。 そのうえ、 TypeScript の型情報を活用できるのが最大の特徴です。

本章では「前章で挙げた課題を Panda CSS がどのように解決したか」に絞って解説します。 セットアップ手順やユーティリティ API などの基本的な使い方は、公式ドキュメントや他の紹介記事を参考にしてください。

Design Token の階層化

Panda CSS は W3C Design Tokens Community Group が策定しているデザイントークンをファーストクラスサポートしています。 Panda CSS の設定ファイルでは core token と semantic token を分けられますが、使うときは core token も制限なく参照できます。 そこで core と semantic を厳密にわけるため、あえて "触ってはいけない" という意味を込めて not.recommend.* という prefix を導入しました。 実際の設定は以下のようになります。

const ikyu = process.env.VITE_MODE !== 'ppg'
// PayPay グルメのリニューアルの公表前なので、
// CSS のカスタムプロパティに PPG という名前が出ないようにしている
const ppg = {
  blue: {
    dark: { value: '#3895FF' },
    basic: { value: '#4DA0FF' },
    light: { value: '#BADAFF' },
    pale: { value: '#E5F1FF' },
  },
  /* …ほかの primitive */
}
export default defineConfig({
  theme: {
    // core token
    tokens: {
      not: {
        recommend: {
          ikyu: {
            red: { value: '#FF4D4D' },
            /* …ほかの primitive */
          },
        }
      }
    },

    // semantic token
    semanticTokens: {
      colors: {
        reserve: {
          value: ikyu ? { colors.not.recommend.ikyu.red } : ppg.blue.dark
        },
        /* …ほかの semantic */
      },
    },
  }
})

core token (primitive) である not.recommend.* 以下のトークンは自動補完には出てきますが、名前からして「コンポーネントで使うものではない」と一目で分かります。 そしてコンポーネント側は以下のように色名ではなく 役割名(reserveButton / login / review ...)だけを参照します。

export function ReserveButton() {
  return (
    <button className={css({ color: 'reserve'})}>
      予約へすすむ
    </button>
  )
}

Variant

前述したカラーの課題は、一休レストランと PayPay グルメが同一リポジトリで開発することが決定した時に顕在化しましたが、元々一休レストランのみを開発していた頃から抱えている課題もありました。 それはデザインツールと実装の連携です。 当時は XD から Figma へ移行した直後で Variant 機能を十分に理解できておらず、ボタンやタブの状態を boolean フラグで切り替える実装が散見されました。 例えば、以下のような実装が典型的でした。

import clsx from 'clsx'
import type { PropsWithChildren } from 'react'

export function Button({
  children,
  onClick,
  isPrimary,
}: PropsWithChildren<{ onClick: () => void; isPrimary: boolean }>) {
  return (
    <button
      onClick={onClick}
      // boolean でユーティリティを切り替え
      className={clsx(
        'p-2 border-neutral-400',
        isPrimary && 'bg-red-300',
      )}
      type="button"
    >
      {children}
    </button>
  )
}

このアプローチの問題は、ボタンの状態が増えるたびに boolean props が増え、条件分岐が複雑になることです。 上記の例では props で isPrimary の boolean 値のみを受け取ってますが、他にも isRounded && isLarge ... と膨れ上がりました。 その結果、 className が条件分岐だらけでメンテナンスが困難になりました。 また、Figma で定義されている Variant と UI コンポーネントで持つ状態の粒度が異なっており、認知負荷が高い状態になりました。 そこで tailwind-variants を投入し「variant をコード側で表現しよう」と試みたものの、ガイドライン策定が追いつかず部分採用のまま立ち消えたという苦い経験もありました。

Panda CSS では Variant がデフォルトでサポートされており、 Variant の key/value をそのままコードに反映することができます。

FigmaのButton定義

Figma で color / size を定義している場合、実装側は次のように Figma の variant と同じ名前と値で Panda CSS の cva を定義します。

import type { PropsWithChildren } from 'react'
import { type RecipeVariantProps, cva } from '~/styled-system/css'

// Variant の定義
const button = cva({
  base: {
    borderRadius: 'md',
  },
  variants: {
    color: {
      primary: {
        backgroundGradient: 'button.primary',
        color: 'button.primary.text',
        fontWeight: 'bold',
      },
      secondary: {
        backgroundGradient: 'button.secondary',
        color: 'button.secondary.text',
        fontWeight: 'bold',
      },
      normal: {
        borderWidth: 'thin',
        borderColor: 'button.normal.border',
        backgroundColor: 'button.normal.background',
        color: 'button.normal.text',
      },
    },
    size: {
      xs: {
        paddingX: '3',
        paddingY: '2',
        fontSize: 'sm',
      },
      sm: {
        paddingX: '4',
        paddingY: '3',
        fontSize: 'sm',
      },
      md: {
        paddingX: '4',
        paddingY: '3',
        fontSize: 'md',
      },
      lg: {
        paddingX: '6',
        paddingY: '4',
        fontSize: 'lg',
      },
    },
  },

  /** Figma の “Default” と同義 */
  defaultVariants: {
    color: 'normal',
    size: 'md',
  },
})

type Props = PropsWithChildren<RecipeVariantProps<typeof button> & { onClick?: () => void }>

function Button({ children, ...buttonStyle }: Props) {
  const style = button(buttonStyle)
  return (
    <button className={style} type="button">
      {children}
    </button>
  )
}

Figma と 1:1 になるコンポーネントができました。誤って存在しない Variant 名を指定しても TypeScript の型チェックが効くのも嬉しいところです。

// 使用側
export function ReserveButton() {
  return (
    <Button color="primary" size="md">
      予約へすすむ
    </Button>
  )
}

Figma で定義された Variant に沿ってエンジニアは同じキーを持つ Variant を cva / sva で実装することができます。 このように Figma と実装で Variant の粒度を合わせることができ、デザインと UI コンポーネントの実装がシームレスへと繋がるようになりました。

おわりに

Panda CSS に移行した際、開発チームのメンバーから以下のようなフィードバックがありました。

  • 新しい UI を考える中で、primitive / semantic を峻別して議論ができるようになった
  • デザインと実装がシームレスに繋がった
    • デザイン:パターン(variants)を作る -> 実装:そのパターン単位で cva / sva を作成する
  • Variant の key やトークン名が型安全になり開発体験が良い
  • 古くから css を触っているエンジニアには、Panda CSS だと css property がほぼそのままなのでわかりやすいと好評

Panda CSS 導入時は Tailwind CSS とは異なるスタイル定義で慣れが必要でしたが、概ね好評でした。

移行はまだ完全には終わっておらず、現在も Tailwind CSS が残っているコンポーネントもまだまだあります。 引き続き Panda CSS に書き換えながら、コンポーネントの見直しやデザインガイドラインの整備を進めています。


一休では、本記事でお伝えしたような課題をともに解決するエンジニアを募集しています。

www.ikyu.co.jp

まずはカジュアル面談からお気軽にご応募ください!

https://hrmos.co/pages/ikyu/jobs/1745000651779629061hrmos.co