T-CREATOR

Tailwind CSS でカレンダー・スケジュール UI をデザインする発想法

Tailwind CSS でカレンダー・スケジュール UI をデザインする発想法

現代の Web アプリケーションにおいて、カレンダーやスケジュール機能は欠かせない要素となっています。しかし、美しく使いやすいカレンダー UI を設計するのは、実は非常に奥深い作業です。

Tailwind CSS を使ったカレンダー UI の設計では、単にコードを書くだけでなく、ユーザーの心に寄り添う発想法が重要になります。この記事では、技術的な実装だけでなく、なぜそのような設計をするのかという「発想法」に焦点を当てて解説していきます。

カレンダー UI の基本設計思想

カレンダー UI を設計する際、最も重要なのは「時間の可視化」という概念です。私たちは日々の生活で時間を意識していますが、デジタル上でそれを表現する際には、物理的なカレンダーとは異なる発想が必要になります。

時間の階層構造を理解する

カレンダー UI では、年 → 月 → 週 → 日という階層構造があります。この構造を理解することで、適切なナビゲーション設計が可能になります。

typescript// 時間の階層構造を表現する型定義
interface TimeHierarchy {
  year: number;
  month: number;
  week: number;
  day: number;
  hour?: number;
  minute?: number;
}

// 階層に応じた表示レベルを管理
type DisplayLevel =
  | 'year'
  | 'month'
  | 'week'
  | 'day'
  | 'hour';

ユーザーの心理モデルを考慮する

カレンダーを見る際、ユーザーは「今日は何日?」「今月は何月?」という基本的な質問から始まります。この心理モデルに合わせた設計が重要です。

jsx// ユーザーの心理モデルに合わせたコンポーネント
const CalendarHeader = ({ currentDate, onNavigate }) => {
  return (
    <div className='flex items-center justify-between p-4 bg-white border-b'>
      {/* 現在の年月を明確に表示 */}
      <h2 className='text-xl font-semibold text-gray-800'>
        {currentDate.toLocaleDateString('ja-JP', {
          year: 'numeric',
          month: 'long',
        })}
      </h2>

      {/* 直感的なナビゲーション */}
      <div className='flex space-x-2'>
        <button
          onClick={() => onNavigate('prev')}
          className='p-2 rounded-lg hover:bg-gray-100 transition-colors'
        >
          ←
        </button>
        <button
          onClick={() => onNavigate('today')}
          className='px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600'
        >
          今日
        </button>
        <button
          onClick={() => onNavigate('next')}
          className='p-2 rounded-lg hover:bg-gray-100 transition-colors'
        >
          →
        </button>
      </div>
    </div>
  );
};

情報密度のバランスを取る

カレンダーには多くの情報が詰め込まれがちですが、適切な情報密度を保つことが重要です。空白も重要なデザイン要素として捉えることで、読みやすさが向上します。

レスポンシブデザインの考え方

カレンダー UI の最大の課題は、画面サイズによって表示方法を大きく変える必要があることです。デスクトップでは月表示が主流ですが、モバイルでは週表示や日表示が適している場合があります。

ブレークポイントに応じた表示戦略

Tailwind CSS のブレークポイントを活用して、画面サイズに応じた最適な表示を実現します。

jsx// レスポンシブ対応のカレンダーグリッド
const CalendarGrid = ({ events, currentDate }) => {
  return (
    <div className='grid grid-cols-7 gap-1 md:gap-2 lg:gap-3'>
      {/* 曜日ヘッダー */}
      <div className='hidden md:block col-span-7 grid grid-cols-7 gap-1 md:gap-2 lg:gap-3 mb-2'>
        {['日', '月', '火', '水', '木', '金', '土'].map(
          (day) => (
            <div
              key={day}
              className='text-center text-sm font-medium text-gray-600 py-2'
            >
              {day}
            </div>
          )
        )}
      </div>

      {/* 日付セル */}
      {generateCalendarDays(currentDate).map(
        (day, index) => (
          <div
            key={index}
            className={`
            min-h-[60px] md:min-h-[80px] lg:min-h-[100px]
            p-1 md:p-2 border border-gray-200
            hover:bg-gray-50 transition-colors
            ${
              day.isToday
                ? 'bg-blue-50 border-blue-300'
                : ''
            }
            ${
              day.isCurrentMonth ? 'bg-white' : 'bg-gray-50'
            }
          `}
          >
            {/* 日付表示 */}
            <div
              className={`
            text-xs md:text-sm font-medium
            ${
              day.isToday
                ? 'text-blue-600'
                : 'text-gray-900'
            }
            ${!day.isCurrentMonth ? 'text-gray-400' : ''}
          `}
            >
              {day.date}
            </div>

            {/* イベント表示(モバイルでは省略) */}
            <div className='hidden md:block space-y-1'>
              {day.events?.slice(0, 2).map((event) => (
                <div
                  key={event.id}
                  className='text-xs p-1 bg-blue-100 rounded truncate'
                >
                  {event.title}
                </div>
              ))}
            </div>
          </div>
        )
      )}
    </div>
  );
};

タッチインターフェースの考慮

モバイルデバイスでは、タップやスワイプなどのジェスチャーが重要になります。

jsx// スワイプ対応のカレンダーナビゲーション
const SwipeableCalendar = ({
  children,
  onSwipeLeft,
  onSwipeRight,
}) => {
  const [touchStart, setTouchStart] = useState(null);
  const [touchEnd, setTouchEnd] = useState(null);

  const handleTouchStart = (e) => {
    setTouchStart(e.targetTouches[0].clientX);
  };

  const handleTouchMove = (e) => {
    setTouchEnd(e.targetTouches[0].clientX);
  };

  const handleTouchEnd = () => {
    if (!touchStart || !touchEnd) return;

    const distance = touchStart - touchEnd;
    const isLeftSwipe = distance > 50;
    const isRightSwipe = distance < -50;

    if (isLeftSwipe) {
      onSwipeLeft();
    }
    if (isRightSwipe) {
      onSwipeRight();
    }

    setTouchStart(null);
    setTouchEnd(null);
  };

  return (
    <div
      onTouchStart={handleTouchStart}
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}
      className='touch-pan-y'
    >
      {children}
    </div>
  );
};

コンポーネント分割の戦略

カレンダー UI は複雑な機能を持つため、適切なコンポーネント分割が重要です。単一責任の原則に従い、再利用可能なコンポーネントを作成しましょう。

原子設計(Atomic Design)の適用

カレンダー UI を原子レベルから構築することで、保守性と再利用性を高めます。

jsx// Atoms: 最小単位のコンポーネント
const CalendarDay = ({
  date,
  isSelected,
  isToday,
  onClick,
  children,
}) => {
  return (
    <button
      onClick={onClick}
      className={`
        w-full h-full p-2 text-left rounded-lg transition-all
        ${isSelected ? 'bg-blue-500 text-white' : ''}
        ${isToday ? 'ring-2 ring-blue-300' : ''}
        hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500
      `}
    >
      <span className='text-sm font-medium'>{date}</span>
      {children}
    </button>
  );
};

// Molecules: 複数のAtomを組み合わせたコンポーネント
const EventItem = ({ event, onEdit, onDelete }) => {
  return (
    <div className='flex items-center justify-between p-2 bg-blue-50 rounded mb-1'>
      <div className='flex-1 min-w-0'>
        <p className='text-sm font-medium text-gray-900 truncate'>
          {event.title}
        </p>
        <p className='text-xs text-gray-500'>
          {event.time}
        </p>
      </div>
      <div className='flex space-x-1'>
        <button
          onClick={() => onEdit(event)}
          className='p-1 text-blue-600 hover:bg-blue-100 rounded'
        >
          ✏️
        </button>
        <button
          onClick={() => onDelete(event.id)}
          className='p-1 text-red-600 hover:bg-red-100 rounded'
        >
          🗑️
        </button>
      </div>
    </div>
  );
};

状態の分離と管理

各コンポーネントの責任を明確に分離し、状態管理を適切に行います。

jsx// カレンダーの状態管理
const useCalendarState = () => {
  const [currentDate, setCurrentDate] = useState(
    new Date()
  );
  const [selectedDate, setSelectedDate] = useState(null);
  const [events, setEvents] = useState([]);
  const [viewMode, setViewMode] = useState('month'); // 'month', 'week', 'day'

  const navigateToMonth = (direction) => {
    const newDate = new Date(currentDate);
    if (direction === 'next') {
      newDate.setMonth(newDate.getMonth() + 1);
    } else {
      newDate.setMonth(newDate.getMonth() - 1);
    }
    setCurrentDate(newDate);
  };

  const goToToday = () => {
    const today = new Date();
    setCurrentDate(today);
    setSelectedDate(today);
  };

  const addEvent = (event) => {
    setEvents((prev) => [
      ...prev,
      { ...event, id: Date.now() },
    ]);
  };

  const updateEvent = (eventId, updates) => {
    setEvents((prev) =>
      prev.map((event) =>
        event.id === eventId
          ? { ...event, ...updates }
          : event
      )
    );
  };

  const deleteEvent = (eventId) => {
    setEvents((prev) =>
      prev.filter((event) => event.id !== eventId)
    );
  };

  return {
    currentDate,
    selectedDate,
    events,
    viewMode,
    setSelectedDate,
    setViewMode,
    navigateToMonth,
    goToToday,
    addEvent,
    updateEvent,
    deleteEvent,
  };
};

状態管理とインタラクション設計

カレンダー UI では、複数の状態が同時に存在し、それらが相互に影響し合います。適切な状態管理とインタラクション設計が、ユーザー体験を左右します。

状態の種類と優先順位

カレンダー UI には以下のような状態が存在します:

typescript// カレンダーの状態定義
interface CalendarState {
  // 表示状態
  currentDate: Date;
  selectedDate: Date | null;
  viewMode: 'month' | 'week' | 'day';

  // データ状態
  events: Event[];
  loading: boolean;
  error: string | null;

  // UI状態
  isEventModalOpen: boolean;
  isDatePickerOpen: boolean;
  dragState: DragState | null;
}

interface DragState {
  type: 'event' | 'date';
  startPosition: { x: number; y: number };
  currentPosition: { x: number; y: number };
  data: any;
}

インタラクションの設計原則

ユーザーの操作に対して、適切なフィードバックを提供することが重要です。

jsx// ドラッグ&ドロップ対応のイベント
const DraggableEvent = ({
  event,
  onDragStart,
  onDragEnd,
}) => {
  const [isDragging, setIsDragging] = useState(false);

  const handleDragStart = (e) => {
    setIsDragging(true);
    e.dataTransfer.setData(
      'text/plain',
      JSON.stringify(event)
    );
    onDragStart(event);
  };

  const handleDragEnd = () => {
    setIsDragging(false);
    onDragEnd();
  };

  return (
    <div
      draggable
      onDragStart={handleDragStart}
      onDragEnd={handleDragEnd}
      className={`
        p-2 bg-blue-100 rounded cursor-move
        ${
          isDragging
            ? 'opacity-50 scale-95'
            : 'hover:bg-blue-200'
        }
        transition-all duration-200
      `}
    >
      <p className='text-sm font-medium'>{event.title}</p>
      <p className='text-xs text-gray-600'>{event.time}</p>
    </div>
  );
};

// ドロップゾーンの実装
const DropZone = ({ onDrop, children }) => {
  const [isOver, setIsOver] = useState(false);

  const handleDragOver = (e) => {
    e.preventDefault();
    setIsOver(true);
  };

  const handleDragLeave = () => {
    setIsOver(false);
  };

  const handleDrop = (e) => {
    e.preventDefault();
    setIsOver(false);
    const eventData = JSON.parse(
      e.dataTransfer.getData('text/plain')
    );
    onDrop(eventData);
  };

  return (
    <div
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
      className={`
        min-h-[100px] border-2 border-dashed rounded-lg
        ${
          isOver
            ? 'border-blue-400 bg-blue-50'
            : 'border-gray-300'
        }
        transition-colors duration-200
      `}
    >
      {children}
    </div>
  );
};

エラーハンドリングとユーザーフィードバック

エラーが発生した場合でも、ユーザーに適切な情報を提供します。

jsx// エラー状態の表示
const ErrorBoundary = ({ children }) => {
  const [hasError, setHasError] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    const handleError = (error) => {
      setHasError(true);
      setError(error);
      // エラーログの送信
      console.error('Calendar Error:', error);
    };

    window.addEventListener('error', handleError);
    return () =>
      window.removeEventListener('error', handleError);
  }, []);

  if (hasError) {
    return (
      <div className='p-4 bg-red-50 border border-red-200 rounded-lg'>
        <div className='flex items-center'>
          <div className='flex-shrink-0'>
            <svg
              className='h-5 w-5 text-red-400'
              viewBox='0 0 20 20'
              fill='currentColor'
            >
              <path
                fillRule='evenodd'
                d='M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z'
                clipRule='evenodd'
              />
            </svg>
          </div>
          <div className='ml-3'>
            <h3 className='text-sm font-medium text-red-800'>
              カレンダーの読み込みに失敗しました
            </h3>
            <p className='text-sm text-red-700 mt-1'>
              ページを再読み込みしてください。問題が続く場合は、お問い合わせください。
            </p>
            <button
              onClick={() => window.location.reload()}
              className='mt-2 text-sm text-red-800 hover:text-red-900 underline'
            >
              再読み込み
            </button>
          </div>
        </div>
      </div>
    );
  }

  return children;
};

アクセシビリティを考慮した実装

カレンダー UI は、すべてのユーザーが利用できるように設計する必要があります。アクセシビリティは後付けではなく、設計の最初から考慮すべき要素です。

キーボードナビゲーション

スクリーンリーダーユーザーやキーボードユーザーが快適に操作できるようにします。

jsx// キーボードナビゲーション対応のカレンダー
const AccessibleCalendar = ({
  currentDate,
  onDateSelect,
}) => {
  const [focusedDate, setFocusedDate] = useState(null);

  const handleKeyDown = (e, date) => {
    switch (e.key) {
      case 'ArrowLeft':
        e.preventDefault();
        navigateDate(date, -1);
        break;
      case 'ArrowRight':
        e.preventDefault();
        navigateDate(date, 1);
        break;
      case 'ArrowUp':
        e.preventDefault();
        navigateDate(date, -7);
        break;
      case 'ArrowDown':
        e.preventDefault();
        navigateDate(date, 7);
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        onDateSelect(date);
        break;
      case 'Home':
        e.preventDefault();
        setFocusedDate(getFirstDayOfWeek(date));
        break;
      case 'End':
        e.preventDefault();
        setFocusedDate(getLastDayOfWeek(date));
        break;
    }
  };

  const navigateDate = (currentDate, days) => {
    const newDate = new Date(currentDate);
    newDate.setDate(newDate.getDate() + days);
    setFocusedDate(newDate);
  };

  return (
    <div role='grid' aria-label='カレンダー'>
      {/* 曜日ヘッダー */}
      <div role='row' className='grid grid-cols-7'>
        {['日', '月', '火', '水', '木', '金', '土'].map(
          (day) => (
            <div
              key={day}
              role='columnheader'
              className='p-2 text-center font-medium'
            >
              {day}
            </div>
          )
        )}
      </div>

      {/* 日付グリッド */}
      <div role='rowgroup'>
        {generateCalendarWeeks(currentDate).map(
          (week, weekIndex) => (
            <div
              key={weekIndex}
              role='row'
              className='grid grid-cols-7'
            >
              {week.map((date, dayIndex) => (
                <button
                  key={dayIndex}
                  role='gridcell'
                  tabIndex={
                    focusedDate?.getTime() ===
                    date?.getTime()
                      ? 0
                      : -1
                  }
                  aria-selected={
                    selectedDate?.getTime() ===
                    date?.getTime()
                  }
                  aria-label={`${date?.getDate()}`}
                  onKeyDown={(e) => handleKeyDown(e, date)}
                  onClick={() => onDateSelect(date)}
                  className={`
                  p-2 text-left border border-gray-200
                  focus:outline-none focus:ring-2 focus:ring-blue-500
                  ${
                    date?.getTime() ===
                    selectedDate?.getTime()
                      ? 'bg-blue-500 text-white'
                      : ''
                  }
                  ${isToday(date) ? 'font-bold' : ''}
                `}
                >
                  {date?.getDate()}
                </button>
              ))}
            </div>
          )
        )}
      </div>
    </div>
  );
};

スクリーンリーダー対応

スクリーンリーダーユーザーが情報を適切に理解できるように、ARIA 属性を活用します。

jsx// スクリーンリーダー対応のイベント表示
const AccessibleEventList = ({ events, selectedDate }) => {
  return (
    <div role='region' aria-label='イベント一覧'>
      <h3 className='sr-only'>
        {selectedDate?.toLocaleDateString('ja-JP')}
        のイベント
      </h3>

      {events.length === 0 ? (
        <p
          className='text-gray-500 text-center py-4'
          role='status'
        >
          この日にはイベントがありません
        </p>
      ) : (
        <ul
          role='list'
          aria-label={`${events.length}件のイベント`}
        >
          {events.map((event, index) => (
            <li key={event.id} role='listitem'>
              <div
                className='p-3 border border-gray-200 rounded-lg mb-2'
                role='article'
                aria-labelledby={`event-title-${event.id}`}
              >
                <h4
                  id={`event-title-${event.id}`}
                  className='font-medium text-gray-900'
                >
                  {event.title}
                </h4>
                <p className='text-sm text-gray-600 mt-1'>
                  <time dateTime={event.startTime}>
                    {formatTime(event.startTime)} -{' '}
                    {formatTime(event.endTime)}
                  </time>
                </p>
                {event.description && (
                  <p className='text-sm text-gray-700 mt-2'>
                    {event.description}
                  </p>
                )}
              </div>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

色覚異常への配慮

色だけでなく、形やパターンでも情報を伝えるようにします。

jsx// 色覚異常に配慮したイベント表示
const ColorBlindFriendlyEvent = ({ event, type }) => {
  const getEventStyle = (type) => {
    const styles = {
      meeting: {
        bg: 'bg-blue-100',
        border: 'border-blue-300',
        icon: '👥',
        pattern:
          'bg-gradient-to-r from-blue-100 to-blue-200',
      },
      deadline: {
        bg: 'bg-red-100',
        border: 'border-red-300',
        icon: '⏰',
        pattern: 'bg-gradient-to-r from-red-100 to-red-200',
      },
      personal: {
        bg: 'bg-green-100',
        border: 'border-green-300',
        icon: '👤',
        pattern:
          'bg-gradient-to-r from-green-100 to-green-200',
      },
    };
    return styles[type] || styles.meeting;
  };

  const style = getEventStyle(type);

  return (
    <div
      className={`
      p-2 rounded-lg border-l-4 ${style.border}
      ${style.pattern} relative overflow-hidden
    `}
    >
      {/* パターンオーバーレイ(色覚異常者向け) */}
      <div className='absolute inset-0 opacity-20'>
        <div
          className='w-full h-full'
          style={{
            backgroundImage: `repeating-linear-gradient(
            45deg,
            transparent,
            transparent 2px,
            rgba(0,0,0,0.1) 2px,
            rgba(0,0,0,0.1) 4px
          )`,
          }}
        />
      </div>

      <div className='relative z-10 flex items-center space-x-2'>
        <span className='text-lg'>{style.icon}</span>
        <div className='flex-1 min-w-0'>
          <p className='text-sm font-medium text-gray-900 truncate'>
            {event.title}
          </p>
          <p className='text-xs text-gray-600'>
            {event.time}
          </p>
        </div>
      </div>
    </div>
  );
};

パフォーマンス最適化のアプローチ

カレンダー UI は大量のデータを扱うことが多く、パフォーマンスが重要な要素になります。適切な最適化により、スムーズなユーザー体験を提供できます。

仮想化(Virtualization)の活用

大量のイベントを表示する際は、画面に表示される部分のみをレンダリングします。

jsx// 仮想化されたイベントリスト
const VirtualizedEventList = ({
  events,
  itemHeight = 60,
}) => {
  const [scrollTop, setScrollTop] = useState(0);
  const containerRef = useRef(null);
  const containerHeight = 400; // 固定のコンテナ高さ

  // 表示範囲の計算
  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(
    startIndex +
      Math.ceil(containerHeight / itemHeight) +
      1,
    events.length
  );

  const visibleEvents = events.slice(startIndex, endIndex);
  const totalHeight = events.length * itemHeight;
  const offsetY = startIndex * itemHeight;

  const handleScroll = (e) => {
    setScrollTop(e.target.scrollTop);
  };

  return (
    <div
      ref={containerRef}
      className='overflow-auto border border-gray-200 rounded-lg'
      style={{ height: containerHeight }}
      onScroll={handleScroll}
    >
      <div
        style={{
          height: totalHeight,
          position: 'relative',
        }}
      >
        <div
          style={{ transform: `translateY(${offsetY}px)` }}
        >
          {visibleEvents.map((event, index) => (
            <div
              key={event.id}
              style={{ height: itemHeight }}
              className='border-b border-gray-100 p-3'
            >
              <h4 className='font-medium text-gray-900'>
                {event.title}
              </h4>
              <p className='text-sm text-gray-600'>
                {event.time}
              </p>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

メモ化による不要な再レンダリングの防止

React.memo や useMemo を活用して、不要な再レンダリングを防ぎます。

jsx// メモ化されたカレンダーセル
const CalendarCell = React.memo(
  ({ date, events, isSelected, onSelect }) => {
    const cellEvents = useMemo(() => {
      return events.filter((event) =>
        isSameDay(new Date(event.date), date)
      );
    }, [events, date]);

    const handleClick = useCallback(() => {
      onSelect(date);
    }, [date, onSelect]);

    return (
      <div
        onClick={handleClick}
        className={`
        min-h-[80px] p-2 border border-gray-200 cursor-pointer
        ${
          isSelected
            ? 'bg-blue-500 text-white'
            : 'hover:bg-gray-50'
        }
        transition-colors duration-200
      `}
      >
        <div className='text-sm font-medium mb-1'>
          {date.getDate()}
        </div>
        <div className='space-y-1'>
          {cellEvents.slice(0, 2).map((event) => (
            <div
              key={event.id}
              className='text-xs p-1 bg-blue-100 rounded truncate'
            >
              {event.title}
            </div>
          ))}
          {cellEvents.length > 2 && (
            <div className='text-xs text-gray-500'>
              +{cellEvents.length - 2}件
            </div>
          )}
        </div>
      </div>
    );
  }
);

// パフォーマンス監視用のフック
const usePerformanceMonitor = (componentName) => {
  useEffect(() => {
    const startTime = performance.now();

    return () => {
      const endTime = performance.now();
      const duration = endTime - startTime;

      if (duration > 16) {
        // 60fpsを下回る場合
        console.warn(
          `${componentName} took ${duration.toFixed(
            2
          )}ms to render`
        );
      }
    };
  });
};

データの遅延読み込み

必要なデータのみを段階的に読み込み、初期表示を高速化します。

jsx// 遅延読み込み対応のカレンダー
const LazyLoadingCalendar = ({ currentDate }) => {
  const [events, setEvents] = useState({});
  const [loadingStates, setLoadingStates] = useState({});

  const loadMonthEvents = useCallback(
    async (year, month) => {
      const key = `${year}-${month}`;

      if (events[key] || loadingStates[key]) return;

      setLoadingStates((prev) => ({
        ...prev,
        [key]: true,
      }));

      try {
        // API呼び出しをシミュレート
        const response = await fetch(
          `/api/events?year=${year}&month=${month}`
        );
        const monthEvents = await response.json();

        setEvents((prev) => ({
          ...prev,
          [key]: monthEvents,
        }));
      } catch (error) {
        console.error('Failed to load events:', error);
      } finally {
        setLoadingStates((prev) => ({
          ...prev,
          [key]: false,
        }));
      }
    },
    [events, loadingStates]
  );

  // 現在の月と前後の月を事前読み込み
  useEffect(() => {
    const year = currentDate.getFullYear();
    const month = currentDate.getMonth();

    loadMonthEvents(year, month);
    loadMonthEvents(year, month - 1);
    loadMonthEvents(year, month + 1);
  }, [currentDate, loadMonthEvents]);

  return (
    <div className='calendar-container'>
      {loadingStates[
        `${currentDate.getFullYear()}-${currentDate.getMonth()}`
      ] && (
        <div className='absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center'>
          <div className='animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500'></div>
        </div>
      )}

      {/* カレンダーグリッド */}
      <CalendarGrid
        currentDate={currentDate}
        events={
          events[
            `${currentDate.getFullYear()}-${currentDate.getMonth()}`
          ] || []
        }
      />
    </div>
  );
};

バンドルサイズの最適化

必要な機能のみをインポートし、バンドルサイズを最小限に抑えます。

jsx// 動的インポートによるコード分割
const EventModal = lazy(() => import('./EventModal'));
const DatePicker = lazy(() => import('./DatePicker'));

const Calendar = () => {
  const [showEventModal, setShowEventModal] =
    useState(false);
  const [showDatePicker, setShowDatePicker] =
    useState(false);

  return (
    <div className='calendar'>
      {/* メインカレンダー */}
      <CalendarGrid />

      {/* 遅延読み込みされるモーダル */}
      <Suspense
        fallback={<div className='loading-spinner' />}
      >
        {showEventModal && (
          <EventModal
            onClose={() => setShowEventModal(false)}
          />
        )}
      </Suspense>

      <Suspense
        fallback={<div className='loading-spinner' />}
      >
        {showDatePicker && (
          <DatePicker
            onClose={() => setShowDatePicker(false)}
          />
        )}
      </Suspense>
    </div>
  );
};

まとめ

Tailwind CSS でカレンダー・スケジュール UI をデザインする際の「発想法」について、技術的な実装だけでなく、なぜそのような設計をするのかという根本的な考え方を解説してきました。

重要なのは、単にコードを書くことではなく、ユーザーの心に寄り添う設計思想を持つことです。時間という抽象的な概念を、どのように視覚的に表現し、どのように操作しやすくするか。その答えは、ユーザーの心理モデルを理解し、適切な技術を選択することにあります。

レスポンシブデザイン、コンポーネント分割、状態管理、アクセシビリティ、パフォーマンス最適化。これらすべてが、最終的にはユーザー体験の向上につながります。

カレンダー UI は、一見シンプルに見えて実は非常に複雑なインターフェースです。しかし、適切な発想法と技術を組み合わせることで、美しく使いやすいカレンダー UI を作成することができます。

今回紹介した発想法を参考に、あなた独自のカレンダー UI を設計してみてください。ユーザーの心に響く、素晴らしいインターフェースが生まれることを期待しています。

関連リンク