T-CREATOR

コンポーネント設計とTailwind:再利用可能なUIの作り方

コンポーネント設計とTailwind:再利用可能なUIの作り方

モダンといわるWeb開発において、再利用可能な UI コンポーネントの構築は効率的な開発の鍵となっています。特に Tailwind CSS のようなユーティリティファーストのフレームワークを使う場合、どのようにコンポーネントを設計するかは重要な課題です。

今回は、Tailwind CSS を活用したデザインシステムの構築方法について、具体的なアプローチを紹介します。特に Atomic Design の原則を取り入れながら、一貫性のある再利用可能な UI コンポーネントをどのように作成するかを解説していきます。

背景(モダン UI におけるコンポーネント設計の変遷)

コンポーネント思考の進化

ウェブ開発におけるコンポーネント思考は、過去 10 年間で大きく進化してきました。かつてはサイト全体をテンプレートとして捉え、ページ単位で設計する方法が一般的でした。しかし、現在では以下のような変化が起きています:

  1. モノリシックからモジュラーへ

    大きなテンプレートから、小さく再利用可能なコンポーネントへの移行が進みました。これにより、開発の効率化とコードの再利用性が高まっています。

  2. ページからコンポーネントへ

    「ページ」という概念から、ページを構成する「コンポーネント」という考え方へのシフトが起こりました。これにより、UI の一貫性を保ちながら複雑なインターフェイスを構築できるようになっています。

  3. フレームワークの進化

    React、Vue、Angular などのフレームワークはコンポーネントベースの開発を中心に据えており、モダン UI 開発においてコンポーネント思考は不可欠となっています。

CSS アプローチの変遷

同時に、CSS の書き方も大きく変化してきました:

  1. 従来の CSS

    選択子に基づいたスタイル定義で、全体的なルールとしてスタイルを適用していました。

  2. オブジェクト指向 CSS (OOCSS) / BEM

    より体系的に命名規則を設けることで、CSS の管理を改善する手法が登場しました。

  3. CSS-in-JS

    JavaScript と CSS を密接に結びつけ、コンポーネントごとにスタイルをスコープする方法が人気を集めました。

  4. ユーティリティファースト CSS

    Tailwind のようなユーティリティクラスのコレクションにより、HTML に直接スタイルを適用する方法が広まっています。

Tailwind とコンポーネント設計の相性

Tailwind CSS は最初、コンポーネント志向と相性が悪いと考えられていました。なぜなら:

  • コンポーネントは再利用を促進するが、Tailwind はインラインでスタイルを適用する
  • コンポーネントは抽象化を目指すが、Tailwind は具体的なスタイルを HTML に直接記述する

しかし実際には、Tailwind とコンポーネント設計は非常に相性が良いことがわかってきました:

  • コンポーネント内部で Tailwind クラスを使用することで、スタイルのカプセル化が可能
  • コンポーネントの再利用により、Tailwind の長いクラス名の問題が軽減される
  • デザイントークン(色、スペーシング、フォントなど)は Tailwind の設定で一元管理できる

課題(一貫性のあるデザインシステム構築の難しさ)

しかし、Tailwind を使用したデザインシステムの構築には、いくつかの課題があります:

一貫性の維持

Tailwind の自由度は諸刃の剣です。開発者がそれぞれ独自のスタイルを適用できる柔軟性がある一方で、一貫性のある UI を維持するのが難しくなります。

html<!-- 開発者Aによるボタン -->
<button class="px-4 py-2 bg-blue-500 text-white rounded">
  ボタンA
</button>

<!-- 開発者Bによるボタン -->
<button class="px-3 py-1 bg-blue-600 text-white rounded-lg">
  ボタンB
</button>

このように、同じ「プライマリーボタン」でも実装が異なると UI の一貫性が損なわれます。

抽象化レベルの決定

コンポーネントの抽象化レベルを決めるのも難しい課題です:

  • 高度に抽象化されたコンポーネント:柔軟性が低下する可能性がある
  • 低レベルの抽象化:再利用性が低くなり、コードの重複が増える
  • 中間レベルの抽象化:バランスが難しく、API 設計が複雑になる

バリエーションの管理

UI コンポーネントには多くのバリエーションが存在します:

  • サイズ(小、中、大)
  • バリアント(プライマリー、セカンダリー、危険、警告など)
  • 状態(通常、ホバー、フォーカス、無効など)
  • レスポンシブな振る舞い

これらすべてを管理しながら一貫性を保つことは容易ではありません。

ドキュメント化と共有

作成したコンポーネントを他の開発者が効果的に使用できるよう、ドキュメント化することも課題です:

  • どのような props があるか
  • どのようなバリエーションがあるか
  • 使用例とベストプラクティス
  • デザインの意図と使用ガイドライン

解決策(Atomic Design との組み合わせ方)

これらの課題に対処するために、Atomic Design の原則と Tailwind を組み合わせる方法を見ていきましょう。

Atomic Design とは

Atomic Design(アトミックデザイン)は、Brad Frost によって提唱されたデザインシステム構築のための方法論です。化学の原子から分子、生物へと複雑な構造が構築されていくように、UI も基本的な要素から複雑なインターフェースを構築するというアプローチです。

5 つの階層に分けられます:

  1. Atoms(原子):ボタン、入力フィールド、ラベルなどの最小単位の UI 要素
  2. Molecules(分子):複数のアトムを組み合わせた機能ユニット(検索フォームなど)
  3. Organisms(生体):分子やアトムが集まった複雑な UI 部品(ヘッダー、商品カードなど)
  4. Templates(テンプレート):オーガニズムを配置したページのワイヤーフレーム
  5. Pages(ページ):実際のコンテンツを含む完成したページ

Tailwind と Atomic Design の統合

Tailwind CSS と Atomic Design を組み合わせる方法はいくつかあります:

1. デザイントークンの定義

Tailwind の設定ファイル(tailwind.config.js)でデザイントークンを定義します:

javascript// tailwind.config.js
module.exports = {
  theme: {
    colors: {
      primary: {
        50: '#f0f9ff',
        100: '#e0f2fe',
        // ...他の色調
        500: '#0ea5e9',
        600: '#0284c7',
        // ...
      },
      secondary: {
        // セカンダリーカラー
      },
      // 他のカラーパレット
    },
    spacing: {
      // カスタムスペーシング
    },
    borderRadius: {
      // カスタムボーダー半径
    },
    // その他のデザイントークン
  },
};

これにより、bg-primary-500text-secondary-700のようなクラスで一貫したカラーパレットを使用できます。

2. Atomsの構築

基本的な UI 要素を構築します。例えば、ボタンコンポーネント:

jsx// React + Tailwind の例
function Button({
  variant = 'primary',
  size = 'md',
  children,
  ...props
}) {
  // バリアントに基づくクラス
  const variantClasses = {
    primary:
      'bg-primary-500 text-white hover:bg-primary-600',
    secondary:
      'bg-gray-200 text-gray-800 hover:bg-gray-300',
    danger: 'bg-red-500 text-white hover:bg-red-600',
  };

  // サイズに基づくクラス
  const sizeClasses = {
    sm: 'px-2 py-1 text-sm',
    md: 'px-4 py-2',
    lg: 'px-6 py-3 text-lg',
  };

  // 共通クラス
  const baseClasses =
    'font-medium rounded focus:outline-none focus:ring-2 transition-colors';

  return (
    <button
      className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]}`}
      {...props}
    >
      {children}
    </button>
  );
}

3. Molecules の構築

アトムを組み合わせて機能単位を作ります:

jsxfunction SearchForm() {
  return (
    <div className='flex'>
      <Input
        type='text'
        placeholder='検索キーワード'
        className='rounded-r-none'
      />
      <Button variant='primary' className='rounded-l-none'>
        検索
      </Button>
    </div>
  );
}

4. Organisms の構築

分子とアトムを組み合わせたより複雑な UI コンポーネント:

jsxfunction ProductCard({ product }) {
  return (
    <div className='bg-white rounded-lg shadow overflow-hidden'>
      <img
        src={product.image}
        alt={product.name}
        className='w-full h-48 object-cover'
      />
      <div className='p-4'>
        <h3 className='font-bold text-lg'>
          {product.name}
        </h3>
        <p className='text-gray-600 mt-1'>
          {product.description}
        </p>
        <div className='mt-4 flex justify-between items-center'>
          <span className='font-bold text-xl'>
            ¥{product.price}
          </span>
          <Button variant='primary' size='sm'>
            カートに追加
          </Button>
        </div>
      </div>
    </div>
  );
}

5. テンプレートとページの構築

オーガニズムを配置してテンプレートを作り、実際のデータをバインドしてページを構築します。

Tailwind でのコンポーネント抽象化戦略

Tailwind を使用したコンポーネント抽象化には主に 3 つのアプローチがあります:

  1. Composition(コンポジション)

    プロパティを通じて振る舞いをカスタマイズできる再利用可能なコンポーネントを作成します。

  2. Extraction(抽出)

    繰り返し使用される Tailwind クラスのパターンを抽出して、コンポーネントとして再利用します。

  3. Extension(拡張)

    基本コンポーネントを拡張して、よりカスタマイズされたコンポーネントを作成します。

具体例(完全なデザインシステム構築プロセス)

実際に Tailwind を使用してデザインシステムを構築するプロセスを見ていきましょう。

ステップ 1:設計原則とデザイントークンの定義

まず、デザインシステムの基礎となる原則とトークンを定義します:

javascript// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          primary: {
            light: '#3b82f6', // blue-500
            DEFAULT: '#2563eb', // blue-600
            dark: '#1d4ed8', // blue-700
          },
          secondary: {
            light: '#a855f7', // purple-500
            DEFAULT: '#9333ea', // purple-600
            dark: '#7e22ce', // purple-700
          },
          neutral: {
            lightest: '#f9fafb', // gray-50
            light: '#f3f4f6', // gray-100
            DEFAULT: '#e5e7eb', // gray-200
            dark: '#d1d5db', // gray-300
            darkest: '#9ca3af', // gray-400
          },
          text: {
            primary: '#111827', // gray-900
            secondary: '#4b5563', // gray-600
            tertiary: '#9ca3af', // gray-400
          },
        },
      },
      spacing: {
        xs: '0.25rem', // 4px
        sm: '0.5rem', // 8px
        md: '1rem', // 16px
        lg: '1.5rem', // 24px
        xl: '2rem', // 32px
        '2xl': '3rem', // 48px
      },
      borderRadius: {
        xs: '0.125rem', // 2px
        sm: '0.25rem', // 4px
        md: '0.375rem', // 6px
        lg: '0.5rem', // 8px
        xl: '0.75rem', // 12px
      },
      fontFamily: {
        sans: ['Inter', 'sans-serif'],
        // その他のフォント
      },
    },
  },
};

ステップ 2:基本的なアトム(Atoms)の作成

jsx// atoms/Button.jsx
import React from 'react';

export function Button({
  variant = 'primary',
  size = 'md',
  children,
  className = '',
  ...props
}) {
  // バリアント別クラス
  const variants = {
    primary:
      'bg-brand-primary text-white hover:bg-brand-primary-dark focus:ring-brand-primary/50',
    secondary:
      'bg-brand-secondary text-white hover:bg-brand-secondary-dark focus:ring-brand-secondary/50',
    outline:
      'bg-transparent border-2 border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-white focus:ring-brand-primary/50',
    ghost:
      'bg-transparent text-brand-primary hover:bg-brand-neutral-light focus:ring-brand-primary/50',
  };

  // サイズ別クラス
  const sizes = {
    sm: 'px-sm py-xs text-sm',
    md: 'px-md py-sm',
    lg: 'px-lg py-md text-lg',
  };

  // 共通クラス
  const baseClasses =
    'inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2';

  return (
    <button
      className={`${baseClasses} ${variants[variant]} ${sizes[size]} ${className}`}
      {...props}
    >
      {children}
    </button>
  );
}

// atoms/Input.jsx
export function Input({
  type = 'text',
  size = 'md',
  className = '',
  error,
  ...props
}) {
  const sizes = {
    sm: 'px-sm py-xs text-sm',
    md: 'px-md py-sm',
    lg: 'px-lg py-md text-lg',
  };

  const baseClasses =
    'w-full border border-brand-neutral-dark rounded-md focus:outline-none focus:ring-2 focus:ring-brand-primary/50 focus:border-brand-primary';
  const errorClasses = error
    ? 'border-red-500 focus:ring-red-500/50 focus:border-red-500'
    : '';

  return (
    <div className='w-full'>
      <input
        type={type}
        className={`${baseClasses} ${sizes[size]} ${errorClasses} ${className}`}
        {...props}
      />
      {error && (
        <p className='mt-1 text-sm text-red-500'>{error}</p>
      )}
    </div>
  );
}

// atoms/Badge.jsx
export function Badge({
  variant = 'default',
  children,
  className = '',
  ...props
}) {
  const variants = {
    default: 'bg-brand-neutral text-brand-text-secondary',
    primary: 'bg-brand-primary/10 text-brand-primary',
    success: 'bg-green-100 text-green-800',
    warning: 'bg-yellow-100 text-yellow-800',
    danger: 'bg-red-100 text-red-800',
  };

  return (
    <span
      className={`inline-flex items-center px-sm py-xs text-xs font-medium rounded-full ${variants[variant]} ${className}`}
      {...props}
    >
      {children}
    </span>
  );
}

ステップ 3:分子(Molecules)の作成

jsx// molecules/SearchForm.jsx
import { Button } from '../atoms/Button';
import { Input } from '../atoms/Input';

export function SearchForm({
  onSearch,
  className = '',
  ...props
}) {
  const handleSubmit = (e) => {
    e.preventDefault();
    const value = e.target.elements.search.value;
    onSearch(value);
  };

  return (
    <form
      onSubmit={handleSubmit}
      className={`flex w-full ${className}`}
      {...props}
    >
      <Input
        name='search'
        placeholder='検索...'
        className='rounded-r-none'
      />
      <Button type='submit' className='rounded-l-none'>
        検索
      </Button>
    </form>
  );
}

// molecules/FormField.jsx
import { Input } from '../atoms/Input';

export function FormField({
  label,
  name,
  error,
  className = '',
  ...props
}) {
  return (
    <div className={`mb-md ${className}`}>
      <label
        htmlFor={name}
        className='block mb-xs text-brand-text-secondary'
      >
        {label}
      </label>
      <Input
        id={name}
        name={name}
        error={error}
        {...props}
      />
    </div>
  );
}

// molecules/Card.jsx
export function Card({
  children,
  className = '',
  ...props
}) {
  return (
    <div
      className={`bg-white border border-brand-neutral rounded-lg shadow-sm overflow-hidden ${className}`}
      {...props}
    >
      {children}
    </div>
  );
}

Card.Header = function CardHeader({
  children,
  className = '',
  ...props
}) {
  return (
    <div
      className={`p-md border-b border-brand-neutral ${className}`}
      {...props}
    >
      {children}
    </div>
  );
};

Card.Body = function CardBody({
  children,
  className = '',
  ...props
}) {
  return (
    <div className={`p-md ${className}`} {...props}>
      {children}
    </div>
  );
};

Card.Footer = function CardFooter({
  children,
  className = '',
  ...props
}) {
  return (
    <div
      className={`p-md border-t border-brand-neutral bg-brand-neutral-lightest ${className}`}
      {...props}
    >
      {children}
    </div>
  );
};

ステップ 4:生体(Organisms)の作成

jsx// organisms/ProductCard.jsx
import { Button } from '../atoms/Button';
import { Badge } from '../atoms/Badge';
import { Card } from '../molecules/Card';

export function ProductCard({
  product,
  onAddToCart,
  className = '',
  ...props
}) {
  return (
    <Card
      className={`h-full flex flex-col ${className}`}
      {...props}
    >
      <div className='relative'>
        <img
          src={product.image}
          alt={product.name}
          className='w-full h-48 object-cover'
        />
        {product.isNew && (
          <Badge
            variant='primary'
            className='absolute top-md right-md'
          >
            新着
          </Badge>
        )}
      </div>

      <Card.Body className='flex-grow'>
        <h3 className='font-bold text-lg text-brand-text-primary'>
          {product.name}
        </h3>
        <p className='mt-sm text-brand-text-secondary'>
          {product.description}
        </p>
      </Card.Body>

      <Card.Footer className='flex justify-between items-center'>
        <span className='font-bold text-xl text-brand-text-primary'>
          ¥{product.price.toLocaleString()}
        </span>
        <Button
          variant='primary'
          size='sm'
          onClick={() => onAddToCart(product)}
        >
          カートに追加
        </Button>
      </Card.Footer>
    </Card>
  );
}

// organisms/ProductGrid.jsx
import { ProductCard } from './ProductCard';

export function ProductGrid({
  products,
  onAddToCart,
  className = '',
  ...props
}) {
  return (
    <div
      className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-md ${className}`}
      {...props}
    >
      {products.map((product) => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={onAddToCart}
        />
      ))}
    </div>
  );
}

// organisms/Navbar.jsx
import { Button } from '../atoms/Button';
import { SearchForm } from '../molecules/SearchForm';

export function Navbar({
  logo,
  onSearch,
  cartItemsCount = 0,
  className = '',
  ...props
}) {
  return (
    <nav
      className={`bg-white border-b border-brand-neutral px-md py-sm ${className}`}
      {...props}
    >
      <div className='container mx-auto flex items-center justify-between'>
        <div className='flex items-center'>
          <img src={logo} alt='Logo' className='h-10' />
        </div>

        <div className='hidden md:flex w-1/3 mx-auto'>
          <SearchForm onSearch={onSearch} />
        </div>

        <div className='flex items-center space-x-md'>
          <Button variant='ghost' size='sm'>
            ログイン
          </Button>
          <Button variant='outline' size='sm'>
            会員登録
          </Button>
          <Button
            variant='primary'
            size='sm'
            className='relative'
          >
            カート
            {cartItemsCount > 0 && (
              <span className='absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center'>
                {cartItemsCount}
              </span>
            )}
          </Button>
        </div>
      </div>
    </nav>
  );
}

ステップ 5:テンプレート(Templates)の作成

jsx// templates/ProductListTemplate.jsx
import { Navbar } from '../organisms/Navbar';
import { ProductGrid } from '../organisms/ProductGrid';

export function ProductListTemplate({
  products,
  onSearch,
  onAddToCart,
  cartItemsCount,
  logo,
}) {
  return (
    <div className='min-h-screen bg-brand-neutral-lightest'>
      <Navbar
        logo={logo}
        onSearch={onSearch}
        cartItemsCount={cartItemsCount}
      />

      <main className='container mx-auto py-xl'>
        <h1 className='text-3xl font-bold text-brand-text-primary mb-lg'>
          商品一覧
        </h1>

        <ProductGrid
          products={products}
          onAddToCart={onAddToCart}
        />
      </main>

      <footer className='bg-white border-t border-brand-neutral py-lg'>
        <div className='container mx-auto text-center text-brand-text-tertiary'>
          © 2023 サンプルストア. All rights reserved.
        </div>
      </footer>
    </div>
  );
}

ステップ 6:ページ(Pages)の作成

jsx// pages/ProductListPage.jsx
import { useState } from 'react';
import { ProductListTemplate } from '../templates/ProductListTemplate';
import logoImage from '../assets/logo.svg';

export function ProductListPage() {
  const [cartItems, setCartItems] = useState([]);
  const [searchQuery, setSearchQuery] = useState('');

  // 実際のアプリケーションでは、APIからデータを取得するロジックがここに入ります
  const products = [
    {
      id: 1,
      name: '高品質レザーバッグ',
      description:
        '上質な本革を使用したハンドメイドのレザーバッグです。',
      price: 24000,
      image: '/images/leather-bag.jpg',
      isNew: true,
    },
    // その他の商品...
  ];

  const filteredProducts = searchQuery
    ? products.filter(
        (product) =>
          product.name
            .toLowerCase()
            .includes(searchQuery.toLowerCase()) ||
          product.description
            .toLowerCase()
            .includes(searchQuery.toLowerCase())
      )
    : products;

  const handleSearch = (query) => {
    setSearchQuery(query);
  };

  const handleAddToCart = (product) => {
    setCartItems((prevItems) => [...prevItems, product]);
  };

  return (
    <ProductListTemplate
      products={filteredProducts}
      onSearch={handleSearch}
      onAddToCart={handleAddToCart}
      cartItemsCount={cartItems.length}
      logo={logoImage}
    />
  );
}

ステップ 7:コンポーネントドキュメントとスタイルガイドの作成

Storybook.js などのツールを使用して、各コンポーネントのドキュメントとスタイルガイドを作成します:

jsx// Button.stories.jsx
import { Button } from './Button';

export default {
  title: 'Atoms/Button',
  component: Button,
  argTypes: {
    variant: {
      options: ['primary', 'secondary', 'outline', 'ghost'],
      control: { type: 'select' },
    },
    size: {
      options: ['sm', 'md', 'lg'],
      control: { type: 'select' },
    },
    onClick: { action: 'clicked' },
  },
};

// テンプレート
const Template = (args) => <Button {...args} />;

// バリエーション
export const Primary = Template.bind({});
Primary.args = {
  variant: 'primary',
  children: 'プライマリーボタン',
};

export const Secondary = Template.bind({});
Secondary.args = {
  variant: 'secondary',
  children: 'セカンダリーボタン',
};

export const Outline = Template.bind({});
Outline.args = {
  variant: 'outline',
  children: 'アウトラインボタン',
};

export const Ghost = Template.bind({});
Ghost.args = {
  variant: 'ghost',
  children: 'ゴーストボタン',
};

export const Small = Template.bind({});
Small.args = {
  size: 'sm',
  children: '小サイズボタン',
};

export const Large = Template.bind({});
Large.args = {
  size: 'lg',
  children: '大サイズボタン',
};

まとめ

Tailwind CSS と Atomic Design を組み合わせたアプローチは、一貫性があり、拡張可能で再利用可能な UI コンポーネントを作成するための強力な方法です。このアプローチの主なメリットは:

  1. 一貫性:デザイントークンと標準化されたコンポーネントにより、アプリケーション全体で一貫した UI を実現できます。

  2. 効率性:コンポーネントを再利用することで、開発時間を短縮し、変更を一度に適用できます。

  3. 拡張性:新しい UI ニーズに対応するために、既存のアトムやパターンを組み合わせて新しいコンポーネントを作成できます。

  4. 保守性:一元化されたデザインシステムにより、大規模なリファクタリングが容易になります。

  5. 協業:デザイナーと開発者が同じ言語と構造を共有することで、コミュニケーションがスムーズになります。

Tailwind CSS を使用したデザインシステムを構築する際の重要なポイントは:

  • 適切な抽象化レベルを見つける:過度な抽象化を避け、必要な柔軟性を維持します。
  • 命名規則を確立する:コンポーネントの名前は、その目的と使用方法を明確に伝える必要があります。
  • ドキュメントを作成する:他の開発者がシステムを理解し、効果的に使用できるようにします。
  • 反復的にアプローチする:一度にすべてを完璧にしようとせず、フィードバックを基に改善します。

最終的に、デザインシステムは単なるコンポーネントのコレクション以上のものであり、チームのコラボレーション、一貫性、効率性を向上させるための基盤となります。

関連リンク