📚

「Figma と違う」をなくす Storybook 運用

に公開

この記事は MICIN Advent Calendar 2025 の17日目の記事です。

https://adventar.org/calendars/11601

前回は小林さんの、入力フォームの権限設計でちょっとだけ賢くなった話 でした。


はじめに

これまで Web アプリケーションのフロントエンドを実装してきて、テストの段階で「実装が Figma と違う」という指摘を受けることが度々ありました。皆さんの中にもこのような経験をされた方がいるかもしれません。
今回は正しいデザインの実装のために、私がこの1年弱 Storybook と Chromatic を使って取り組んできたことを紹介します。

この記事の中でフロントエンドの実装や Storybook の運用に関して述べていますが、あくまで私の個人的見解であり、参考程度に受け取っていただければ幸いです。

解決したい課題

これまでの私の経験では、概ね以下のような流れでプロダクトを作ってきました。(デザインに関する項目だけ書いているので実際はもっと複雑ですが…)

  1. 要求やユースケースが定義される
  2. 上記を満たす理想のデザインが検討され、デザイナーの頭の中で固まる
  3. デザイナーが Figma でデザインを作成する
  4. エンジニアが実装する
  5. STG環境などにデプロイしてテストする
  6. 本番環境にリリースする

この流れの中で、テストの段階で「この画面の〇〇の色が Figma と違う」「この画面の〇〇の大きさが Figma と違う」というような指摘を受けることがありました。このような細かいデザインの違いを修正することはたいてい難しくはないのですが、そもそも実装する時点で正しいデザインを実装できるはずだとも感じていました。

どうやって「正しい」デザインを実装するか

上記のような課題を実際にどうすると解決できるのか、私が考えたことをまとめていきます。

「正しい」デザインとは何か?

ここまで何度か「正しい」デザインという言葉を使っていますが、そもそも何が「正しい」デザインなのでしょうか?
何を正しいとするかは各チームごとに定義するものだと思いますが、私は、先程のプロダクト開発の流れにおける 2. の時点でデザイナーの頭の中にあるデザインが正しいと考えました。

  1. 要求やユースケースが定義される
  2. 上記を満たす理想のデザインが検討され、デザイナーの頭の中で固まる (👈 この時点のデザイン)
  3. デザイナーが Figma でデザインを作成する
  4. エンジニアが実装する
  5. STG環境などにデプロイしてテストする
  6. 本番環境にリリースする

デザイナーはこの正しいデザインを Figma 上で表現しますし、その Figma を見てエンジニアは実装しています。
しかし、Figma はあくまでデザイナーが考えた正しいデザインを表現するために作られたものであり、常に正しいとは限りません(エンジニアが仕様を理解してプログラムを実装しても常に正しいとは限らないのと同じように)。
従ってエンジニアが本来目指すべきデザインは Figma で表現されたデザインではなく、 2. の時点でのデザインのはずだと考えました。

目指したいデザインは定まりましたが、この正しいデザインはデザイナーの頭の中にあり、 Figma のように視覚的に確認できる実態がありません。
次に私は、この実態のない正しいデザインを実装するための方法を考えました。

「正しい」デザインはルールに従う

正しいデザインには実態がないと書きましたが、あるデザインを見たときに「正しくない」と判断できることはあると思います。
例えばある要素の内側の余白が 11px になっていたり、fontSize が 13px になっていたり、他のどこでも使われていない色が突然使われたり。なんとなく余白や文字の大きさは4や8の倍数になっていることが多い気がする…、というような感覚をぼんやり持っている方も少なくないと思います。

つまりデザインには従うべきルールがあるはずです。
正しいデザインを実装するには、このようなルールを定義する必要があります。そしてエンジニアとしては、実装するコードがこのルールを守るような仕組みを整備したいです。

「正しい」デザインを実装する大まかな流れ

ここからはおおよそ以下のような流れでデザインを実装し、正しいデザインが担保された状態を保つことを目指します。

  1. アプリが従うべきルールを定義する
    1. デザイントークンの定義
    2. 共通コンポーネントの定義
  2. デザイントークンと共通コンポーネントを実装する
  3. Storybook を使って実装したデザインをデザイナーに共有し、レビューしてもらう
  4. Chromatic の UI Review を使って正しい状態を保つ

このプロセスの中で私が意識していたのは、「デザイナーが Figma を正しくし、エンジニアはその Figma を再現する」のではなく「デザイナーとエンジニアが一緒に実装のデザインを正しくする」ということでした。
そしてその上で Storybook はデザイナーとエンジニアの共通の成果物になり、デザインを正しく保つにも役立つと考えました。

長々と前置きをしてしまいましたが、具体的に何をしたのか書いていきます。

ルールの定義

正しいデザインを実装するために、まずは私たちのプロダクトが従うべきデザインのルールを定義するところから始めました。

デザイントークン

Material Design ではデザイントークンとは以下のように定義されています。

Design tokens are small, reusable design decisions that make up a design system's visual style. Tokens replace static values with self-explanatory names.

デザイントークンは、デザインシステムのビジュアルスタイルを構成する、小さく再利用可能なデザイン上の決定事項です。トークンは、静的な値をわかりやすい名前に置き換えます。

https://m3.material.io/foundations/design-tokens/overview

デザイントークン自体にここであまり深入りしませんが、色や大きさなどのスタイルの値に名前を付けたものです。

今回私たちは色、テキストスタイル、影のデザイントークンを定義しました。
正しいデザインを実装するには、アプリ内のあらゆる色、テキスト、影はこのデザイントークンの値を使う必要があります。

共通コンポーネント

ButtonInputSelect などの共通コンポーネントについて、デザイナーがそのコンポーネントのバリエーションを定義します。

例えば Button であれば、種類 (fill, outline, ghost)、色、大きさなどによってどんな見た目になるか定義されています。アプリ内のボタンはここに定義されている見た目以外にはなりません。

デザイントークンと共通コンポーネントを実装

デザイナーが定義した正しいデザインの構成要素であるデザイントークンと共通コンポーネントを実装します。
このとき、ただ実装して終わるのではなく、実装したコンポーネントの Storybook をデザイナーにレビューしてもらい、正しく実装できていることを担保します。

例えば Button という共通コンポーネントを実装する流れを考えてみましょう。

  1. 正しい Button の定義が頭の中で固まる (👈 この時点のデザインが正)
  2. デザイナーが Figma で Button のデザインを作成する (👈 正しくないかもしれない)
  3. エンジニアが実装する (👈 正しくないかもしれない)

冒頭でも述べた通り、正しい Button は最初は実態がなく、デザイナーの頭の中にあります。これを Figma 上で表現したものをエンジニアが解釈して実装します。このプロセスの中で当然正しくなくなる可能性があります。

なのでエンジニアが実装した Button を Storybook を使って共有し、デザイナーにレビューしてもらいます。
レビューの結果、エンジニアの実装がデザイナーの意図を正しく読み取れていないかもしれません。どこがなぜ違うのか、フィードバックをもらって実装を修正します。このプロセスを経て、デザイナーとエンジニアの間で「正しい Button」に対する共通の認識も醸成されます。

最終的にデザイナーの意図していた通りに Button が実装され、正しいことが担保されれば、さっきまで実態がなかった「正しい Button」 というコンポーネントの実態が得られます。

このように、共通コンポーネントやデザイントークンのような小さな構成要素から、正しいデザインの実態を少しずつ積み上げていきます。そして徐々に大きなコンポーネント(Atomic Design で言う Molecules や Organisms) も正しいデザインの実態を作っていき、これらによって構成されるアプリケーションのデザインは、全体として正しい状態になることを目指していきます。

ルールを守るための実装

共通コンポーネントはデザイナーにレビューしてもらうので正しい状態にキープできる、と言いたいですがレビュー自体が完璧とは限りませんし、偶発的に正しくない実装をしてしまうこともありえます。なので仕組みでルールを守るための実装もしていきます。

デザイントークンを型で守る

私たちは React, TypeScript, Chakra を使ってフロントエンドを実装していたので、型を使ってデザイントークン以外の値を使えないようにしました。

例えば文字を表示する Text というコンポーネントを以下のように実装しました。

import { ComponentProps, memo } from 'react'

/* eslint-disable no-restricted-imports */
import { Text as ChakraText } from '@chakra-ui/react'
/* eslint-enable no-restricted-imports */

import { customTextStyles } from 'src/theme/typography/text-styles'

// Chakra の Text をベースにしつつ、デザイントークンを守らせるために型を厳密にしている
export type TextProps = Omit<
  ComponentProps<typeof ChakraText>,
  'fontSize' | 'fontWeight' | 'lineHeight'
  // 上記の props を残しておくとデザイントークンのスタイルから逸脱する可能性があるので
  // props から削除し、これらのスタイルを変更できないようにする
> & {
  // textStyle という props で文字のスタイルを指定し、
  // デザイントークンで定義されたスタイルしか適用できないようにする
  textStyle?: keyof typeof customTextStyles
}

export const Text = memo<TextProps>(({ children, ...props }) => {
  return <ChakraText {...props}>{children}</ChakraText>
})
  • Chakra の Text をベースに実装する
  • アプリ内の文字はすべてデザイントークンに定義されたスタイルが適用されるので、props としてデザイントークンを受け取れるようにする
    • textStyle の型は 'Std-16N-175' | 'Std-18N-160' | 'Std-24N-150' のようになる
  • 文字のスタイルは textStyle に与えるデザイントークンだけで決まるので、fontSize などは props の型から削除しておく

このように実装することでアプリ内の文字が必ずデザイントークンに従うようにします。
Text 以外のコンポーネントも同様に型を厳密にして実装します。例えば Stack のようなコンポーネントは backgroundColor, borderColor などの props は色のデザイントークンだけ使えるように型を厳密にし、boxShadow などの props は影のデザイントークンだけが使えるように実装します。

Chakra のコンポーネントを import できないようにする

上記のように、 props の型を厳密に定義することで、デザイントークン以外のスタイルを適用できないようにすることができます。しかし、 TextStack を厳密に定義しても、他のコンポーネントを実装する際に Chakra の TextStack を使ってしまうと、デザイントークン以外の値を設定できてしまいます。

これを防ぐために、 ESLint の no-restricted-imports というルールで、 @chakra-ui/react から特定のコンポーネントは import できないように設定します。

module.exports = {
  rules: {
    'no-restricted-imports': [
      'error',
      {
        paths: [
          {
            name: '@chakra-ui/react',
            importNames: ['Text', 'TextProps', 'Stack', 'StackProps'],
          },
        ],
      },
    ],
  },
}

この設定によって、 TextStack'@chakra-ui/react' から import できなくなります。

import { Text } from '@chakra-ui/react' // エラーになる
import { Text } from 'src/components/Text' // エラーにならない

正しいデザインを保つ方法

デザイナーがレビューしてコンポーネントが正しく実装できたら、この正しい状態を保つ必要があります。そのために今回は Chromatic の UI Review を使うことにしました。

https://www.chromatic.com/docs/review/

以下の画像は Chromatic の公式ドキュメントから拝借してきたものですが、このように Chromatic のコンソール上である2つの時点 (Git のコミット) での UI の差分を検証できます。
また、検証する際には Storybook で作成した各 Story ごとに比較します。

私たちは、PR を作ったときに main ブランチと開発ブランチを比較し、UI の差分を検証する運用にしていました。

ui-review-example

これによって、エンジニアが PR を作った際にデザインが意図せず変更してしまうことを防ぎ、正しいデザインを保ちます。

Chromatic の UI Review は、前述の通り Story 単位で2つの時点を比較して差分を検証します。
なので正しいデザインをしっかり保つためには、 Story を充実させる必要があります。

Story を充実させるための実装

例として、Zenn のヘッダー部分を実装すると考えてみます。
このヘッダーは、以下のようなデザインのバリエーションがあります。

状態 デザイン
ログイン済 zenn-header-authenticated
記事をデプロイ中 zenn-header-loading
記事のデプロイが失敗 zenn-header-deploy-failed
未ログイン zenn-header-unauthenticated

これらのヘッダーのバリエーションを Story として定義するために、コンポーネントは UI を返す関数と考え、コンポーネントを純関数のように実装することが必要だと考えました。

ヘッダーが純粋ではない実装

このヘッダーが以下のように実装されていると考えてみます。

  • 認証状態を管理している useAuth というフックがある
  • デプロイ状態を管理している useDeployment というフックがある
  • ヘッダー自身が useAuthuseDeployment を呼んで、状態に応じてデザインを切り替える

このようなヘッダーを非常に単純に書くと以下のようになるかと思います。

export const Header: FC = () => {
  const { isAuthenticated } = useAuth()
  const { status } = useDeployment()

  return (
    <HStack w='100%' justifyContent='space-between' px={4} py={2}>
      {/* 左側: ロゴ */}
      <ZennLogo />

      {/* 右側: アイコン群 + ボタン */}
      <HStack gap={2} alignItems='center'>
        {/* テーマ切り替えボタン */}
        <IconButton icon={<Moon />} />

        {/* 検索ボタン */}
        <IconButton icon={<Search />} />

        {isAuthenticated ? (
          <>
            {/* デプロイ状態 */}
            <IconButton
              icon={
                status === 'success' ? (
                  <DeploymentSuccess />
                ) : status === 'loading' ? (
                  <DeploymentLoading />
                ) : (
                  <DeploymentFailure />
                )
              }
            />

            {/* 通知ボタン */}
            <IconButton icon={<Notification />} />

            {/* アバター */}
            <Avatar />

            {/* 投稿するボタン */}
            <Button>投稿する</Button>
          </>
        ) : (
          /* ログインボタン */
          <Button>Log in</Button>
        )}
      </HStack>
    </HStack>
  )
}

この実装では Header は props を受け取る必要がなく、自身で状態を取得して表示を出し分けています。このコンポーネントはユーザーが認証しているか、デプロイが成功しているか、という入力以外の状態によって見た目(出力)が変化するので、純粋ではないコンポーネントと言えます。
この Header の実装では、 Story を作るときに isAuthenticatedstatus の値を制御できないので、Header の各状態を Story として再現できません。

export const Primary: Story = {
  args: {
    // ここで props を渡せない
  },
}

ヘッダーが純粋な実装

一方で、ヘッダーが認証の状態やデプロイの状態を props として受け取るような実装を考えてみます。
このような実装は props の値だけで見た目が決まります。つまり同じ入力に対して常に同じ出力を返す純粋なコンポーネントと言えます。

export const Header: FC<{
  isAuthenticated: boolean
  status: 'success' | 'loading' | 'failure'
}> = ({ isAuthenticated, status }) => {
  return (
    // 見た目の部分は同様
  )
}

このように実装すると、 Header の Story を作るときに各状態の Story を作る事ができます。

export const Authenticated: Story = {
  args: {
    // args で isAuthenticated を指定することで認証済の状態を再現できる
    isAuthenticated: true,
    status: 'success',
  },
}

export const Loading: Story = {
  args: {
    isAuthenticated: true,
    status: 'loading',
  },
}

export const Failure: Story = {
  args: {
    isAuthenticated: true,
    status: 'failure',
  },
}

export const Unauthenticated: Story = {
  args: {
    isAuthenticated: false,
  },
}

このようにコンポーネントを純粋にすることで Story を充実させることができ、結果的に UI Review でカバーできる範囲が増えます。

成果

ここまで述べてきたような運用をしてきたことで、色々な成果が得られました。

「正しい」デザインを保つことができた

個人的な体感なので定量的に示せないのですが、比較的正しいデザインを保つ事ができたかなと感じています。
少なくとも、デザイントークンで定義されている色やテキストスタイルに関してテストの段階でミスに気づくことはほぼなかったですし、各画面で使っているコンポーネントが違ったり、コンポーネント自体が正しく実装されていないという指摘もありませんでした。

Chromatic の UI Review が強力

Chromatic の UI Review のおかげで、意図しない UI のデグレは防ぐことができました。
また、他のエンジニアの PR をレビューする際、見た目がどうなっているか確認するのも非常に楽になりました。

デザイナーとの信頼関係の構築

これは当初意図していなかったのですが、デザイナーとの信頼関係が構築できたことは個人的に大きな成果でした。デザイントークンやコンポーネントを定義、実装、レビュー、認識のすり合わせ、最終的に共通の正しいデザインを作っていく、という一連のプロセスを経て、デザイナーとエンジニアが協働してより良いプロダクトを作っていくというのは信頼関係の構築に大きく寄与したと感じました。

QCチームがテストケースを考えるのに役立つ

私が所属している DTx 事業部には QC (Quality Control) のチームがあり、実装したプロダクトのテストをしているのですが、今回 Storybook をQCチームにも参照できるようにしました。
これによって、エンジニアが実装した画面をQCチームも Storybook 上で確認できるようになったのですが、これがテストケースを作るのに役に立ったそうです。
QCチームがテストケースを作る際、実際の画面を見て「このボタンを押したらどうなるのか」など考えられることが助かったそうなのですが、私としては意外な成果でした。

課題

一方で、もちろん課題もありました。

表示するコンテンツの正しさは担保できない

今回述べてきた運用では、コンポーネントや文字のスタイルは正しい状態にできました。しかしそのコンポーネントに表示する文字、イラスト、アイコンなどのコンテンツ部分の正しさは担保できませんでした。
例えば Button というコンポーネントの色、大きさ、形などが正しく実装できたとしても、そのボタンのラベル部分としてどんな文言が正しいかは担保できませんでした。
ボタンに限らずアプリ内のあらゆる文言は開発が進む中で何度も変更されるものだと思いますが、文言が変わった際に実装がもれなくその変更に対応するためには、また別のアプローチが必要なんだと感じます。

props が肥大化する

前述の通り、純粋なコンポーネントを実装するように運用していましたが、この方針だとコンポーネントが大きく複雑になるとすぐに props が肥大化します。
非常に典型的なアンチパターンだと思いますが、これにあまりうまく対応できませんでした。

Compound Pattern などが有効なのか?とも思っていますがあまりわかっていないのでこのあたりは学んでいきたいです。

運用の属人化

正しいデザインを作っていくプロセスの中で、デザイナーからのフィードバックを受け、正しいデザインの認識を共有していたのは、ほぼ私が属人的にやっていました。
信頼関係が構築できた一方、属人性は非常に高まってしまいました。

Figma が「正」ではないという認識の転換

これまでに述べてきたような理由で Figma が正しいデザインではないと個人的に考えていますが、全員がそうではないでしょうし、私のこの考えが常に正しいわけでもないと思います。
今回はたまたま認識を共有できるデザイナー、エンジニアに恵まれたおかげでこのような運用ができたと考えると、この運用をすること自体にハードルがあるだろうと思います。

まとめ

この記事で述べた内容は個人の見解を多く含んでおり、絶対の正解を示すものではありませんが、同じような課題に取り組んでいる方の参考になれば幸いです。

参考資料


MICINではメンバーを大募集しています。
「とりあえず話を聞いてみたい」でも大歓迎ですので、お気軽にご応募ください!

https://recruit.micin.jp/

株式会社MICIN

Discussion