T-CREATOR

Tailwind CSS で通知・トースト UI を実装するコンポーネント設計術

Tailwind CSS で通知・トースト UI を実装するコンポーネント設計術

Web アプリケーションにおいて、ユーザーに適切なフィードバックを提供することは、優れたユーザー体験を実現する上で欠かせない要素です。特に通知やトースト UI は、操作の成功・失敗、重要な情報の提示、システムの状態変化を伝える重要な役割を果たします。

しかし、多くの開発者が直面する課題があります。それは「美しく、使いやすく、保守性の高い通知システムをどのように設計するか」ということです。Tailwind CSS を使えば、この課題を効率的に解決できることを、この記事でお伝えします。

実際のプロジェクトで使える実装例と、よくあるエラーとその解決策を交えながら、段階的に学んでいきましょう。あなたのアプリケーションが、ユーザーにとってより親しみやすく、信頼できるものになることを願っています。

通知・トースト UI の基本概念

通知 UI の種類と特徴

通知 UI には主に以下の種類があります:

種類特徴使用場面
トースト通知画面の一角に一時的に表示操作完了の確認
インライン通知コンテンツ内に直接表示フォームエラー、警告
バナー通知画面の上部または下部に固定重要なシステムメッセージ
モーダル通知画面を覆う形で表示重要な確認や警告

通知の基本要素

効果的な通知 UI には以下の要素が含まれます:

  • 視覚的な階層: 重要度に応じた色分け
  • 明確なメッセージ: 簡潔で理解しやすい文言
  • 適切なタイミング: ユーザーの操作に応じた表示
  • 自動消去機能: 一定時間後の自動非表示
  • 手動制御: ユーザーによる閉じる機能

よくある設計ミスとその影響

多くの開発者が陥りがちな問題があります:

typescript// ❌ 悪い例:色だけで情報を伝えようとする
<div className="bg-red-500 text-white p-4">
  エラーが発生しました
</div>

// ✅ 良い例:アイコンとテキストで明確に伝える
<div className="bg-red-500 text-white p-4 flex items-center">
  <svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
    <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>

Tailwind CSS でのスタイリング基礎

Tailwind CSS の設定と準備

まず、Tailwind CSS をプロジェクトに導入しましょう。

bash# Yarn を使用して Tailwind CSS をインストール
yarn add -D tailwindcss postcss autoprefixer
yarn tailwindcss init -p

tailwind.config.js の基本設定

javascript/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './src/**/*.{js,ts,jsx,tsx}',
    './pages/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
  ],
  theme: {
    extend: {
      // 通知用のカスタムカラーを定義
      colors: {
        notification: {
          success: '#10B981',
          warning: '#F59E0B',
          error: '#EF4444',
          info: '#3B82F6',
        },
      },
      // アニメーション用のキーフレーム
      keyframes: {
        slideIn: {
          '0%': {
            transform: 'translateX(100%)',
            opacity: '0',
          },
          '100%': {
            transform: 'translateX(0)',
            opacity: '1',
          },
        },
        slideOut: {
          '0%': {
            transform: 'translateX(0)',
            opacity: '1',
          },
          '100%': {
            transform: 'translateX(100%)',
            opacity: '0',
          },
        },
      },
      animation: {
        'slide-in': 'slideIn 0.3s ease-out',
        'slide-out': 'slideOut 0.3s ease-in',
      },
    },
  },
  plugins: [],
};

よくある設定エラーと解決策

javascript// ❌ エラー:content パスが正しく設定されていない
module.exports = {
  content: [
    './src/**/*.{js,ts,jsx,tsx}', // このパスが存在しない場合
  ],
  // ...
};

// エラーメッセージ:
// "warn - No utility classes were detected in your source files. If this is unexpected, double-check the `content` option in your Tailwind CSS configuration."

// ✅ 解決策:正しいパスを設定
module.exports = {
  content: [
    './app/**/*.{js,ts,jsx,tsx}',
    './pages/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
  ],
  // ...
};

通知用の基本スタイルクラス

css/* globals.css または styles.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

/* 通知用のカスタムコンポーネントクラス */
@layer components {
  .notification-base {
    @apply fixed z-50 p-4 rounded-lg shadow-lg border-l-4;
  }

  .notification-success {
    @apply bg-green-50 border-green-500 text-green-800;
  }

  .notification-error {
    @apply bg-red-50 border-red-500 text-red-800;
  }

  .notification-warning {
    @apply bg-yellow-50 border-yellow-500 text-yellow-800;
  }

  .notification-info {
    @apply bg-blue-50 border-blue-500 text-blue-800;
  }
}

シンプルな通知コンポーネントの実装

基本的な通知コンポーネント

まず、最もシンプルな通知コンポーネントから始めましょう。

typescript// components/Notification.tsx
import React from 'react';

interface NotificationProps {
  type: 'success' | 'error' | 'warning' | 'info';
  message: string;
  isVisible: boolean;
  onClose: () => void;
}

const Notification: React.FC<NotificationProps> = ({
  type,
  message,
  isVisible,
  onClose,
}) => {
  if (!isVisible) return null;

  const getTypeStyles = () => {
    switch (type) {
      case 'success':
        return 'bg-green-50 border-green-500 text-green-800';
      case 'error':
        return 'bg-red-50 border-red-500 text-red-800';
      case 'warning':
        return 'bg-yellow-50 border-yellow-500 text-yellow-800';
      case 'info':
        return 'bg-blue-50 border-blue-500 text-blue-800';
      default:
        return 'bg-gray-50 border-gray-500 text-gray-800';
    }
  };

  return (
    <div
      className={`fixed top-4 right-4 z-50 p-4 rounded-lg shadow-lg border-l-4 ${getTypeStyles()}`}
    >
      <div className='flex items-center justify-between'>
        <span className='text-sm font-medium'>
          {message}
        </span>
        <button
          onClick={onClose}
          className='ml-4 text-gray-400 hover:text-gray-600'
        >
          <svg
            className='w-4 h-4'
            fill='none'
            stroke='currentColor'
            viewBox='0 0 24 24'
          >
            <path
              strokeLinecap='round'
              strokeLinejoin='round'
              strokeWidth={2}
              d='M6 18L18 6M6 6l12 12'
            />
          </svg>
        </button>
      </div>
    </div>
  );
};

export default Notification;

使用例とエラーハンドリング

typescript// pages/index.tsx または App.tsx
import React, { useState } from 'react';
import Notification from '../components/Notification';

const App: React.FC = () => {
  const [notification, setNotification] = useState({
    isVisible: false,
    type: 'success' as const,
    message: '',
  });

  const showNotification = (
    type: 'success' | 'error' | 'warning' | 'info',
    message: string
  ) => {
    setNotification({
      isVisible: true,
      type,
      message,
    });
  };

  const handleClose = () => {
    setNotification((prev) => ({
      ...prev,
      isVisible: false,
    }));
  };

  return (
    <div className='min-h-screen bg-gray-100 p-8'>
      <div className='max-w-md mx-auto space-y-4'>
        <button
          onClick={() =>
            showNotification(
              'success',
              '操作が完了しました!'
            )
          }
          className='w-full bg-green-500 text-white py-2 px-4 rounded hover:bg-green-600'
        >
          成功通知を表示
        </button>

        <button
          onClick={() =>
            showNotification(
              'error',
              'エラーが発生しました'
            )
          }
          className='w-full bg-red-500 text-white py-2 px-4 rounded hover:bg-red-600'
        >
          エラー通知を表示
        </button>
      </div>

      <Notification
        type={notification.type}
        message={notification.message}
        isVisible={notification.isVisible}
        onClose={handleClose}
      />
    </div>
  );
};

export default App;

よくある実装エラーと解決策

typescript// ❌ エラー:TypeScript の型エラー
interface NotificationProps {
  type: 'success' | 'error' | 'warning' | 'info';
  message: string;
  isVisible: boolean;
  onClose: () => void;
}

const Notification: React.FC<NotificationProps> = ({
  type,
  message,
  isVisible,
  onClose,
}) => {
  // エラー:Type 'string' is not assignable to type 'success' | 'error' | 'warning' | 'info'
  const getTypeStyles = (type: string) => {
    // ...
  };
};

// ✅ 解決策:型を正しく指定
const getTypeStyles = (
  type: 'success' | 'error' | 'warning' | 'info'
) => {
  // ...
};

アニメーション付きトーストの作成

アニメーション機能の実装

次に、スムーズなアニメーションを追加して、より洗練されたトースト通知を作成しましょう。

typescript// components/AnimatedToast.tsx
import React, { useEffect, useState } from 'react';

interface ToastProps {
  type: 'success' | 'error' | 'warning' | 'info';
  message: string;
  duration?: number;
  isVisible: boolean;
  onClose: () => void;
}

const AnimatedToast: React.FC<ToastProps> = ({
  type,
  message,
  duration = 5000,
  isVisible,
  onClose,
}) => {
  const [isAnimating, setIsAnimating] = useState(false);

  useEffect(() => {
    if (isVisible) {
      setIsAnimating(true);

      // 自動消去のタイマー
      const timer = setTimeout(() => {
        handleClose();
      }, duration);

      return () => clearTimeout(timer);
    }
  }, [isVisible, duration]);

  const handleClose = () => {
    setIsAnimating(false);
    setTimeout(() => {
      onClose();
    }, 300); // アニメーション完了後に非表示
  };

  const getTypeStyles = () => {
    switch (type) {
      case 'success':
        return 'bg-green-500 text-white';
      case 'error':
        return 'bg-red-500 text-white';
      case 'warning':
        return 'bg-yellow-500 text-white';
      case 'info':
        return 'bg-blue-500 text-white';
      default:
        return 'bg-gray-500 text-white';
    }
  };

  const getIcon = () => {
    switch (type) {
      case 'success':
        return (
          <svg
            className='w-5 h-5'
            fill='currentColor'
            viewBox='0 0 20 20'
          >
            <path
              fillRule='evenodd'
              d='M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
              clipRule='evenodd'
            />
          </svg>
        );
      case 'error':
        return (
          <svg
            className='w-5 h-5'
            fill='currentColor'
            viewBox='0 0 20 20'
          >
            <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>
        );
      default:
        return null;
    }
  };

  if (!isVisible) return null;

  return (
    <div className='fixed top-4 right-4 z-50'>
      <div
        className={`
          ${getTypeStyles()} 
          p-4 rounded-lg shadow-lg 
          flex items-center space-x-3
          transform transition-all duration-300 ease-out
          ${
            isAnimating
              ? 'translate-x-0 opacity-100'
              : 'translate-x-full opacity-0'
          }
        `}
      >
        {getIcon()}
        <span className='text-sm font-medium'>
          {message}
        </span>
        <button
          onClick={handleClose}
          className='ml-2 text-white hover:text-gray-200'
        >
          <svg
            className='w-4 h-4'
            fill='none'
            stroke='currentColor'
            viewBox='0 0 24 24'
          >
            <path
              strokeLinecap='round'
              strokeLinejoin='round'
              strokeWidth={2}
              d='M6 18L18 6M6 6l12 12'
            />
          </svg>
        </button>
      </div>
    </div>
  );
};

export default AnimatedToast;

アニメーション用のカスタムフック

typescript// hooks/useToast.ts
import { useState, useCallback } from 'react';

interface ToastState {
  isVisible: boolean;
  type: 'success' | 'error' | 'warning' | 'info';
  message: string;
}

export const useToast = () => {
  const [toast, setToast] = useState<ToastState>({
    isVisible: false,
    type: 'info',
    message: '',
  });

  const showToast = useCallback(
    (
      type: 'success' | 'error' | 'warning' | 'info',
      message: string
    ) => {
      setToast({
        isVisible: true,
        type,
        message,
      });
    },
    []
  );

  const hideToast = useCallback(() => {
    setToast((prev) => ({ ...prev, isVisible: false }));
  }, []);

  return {
    toast,
    showToast,
    hideToast,
  };
};

アニメーション関連のよくあるエラー

typescript// ❌ エラー:アニメーションが正しく動作しない
useEffect(() => {
  if (isVisible) {
    setIsAnimating(true);
    const timer = setTimeout(() => {
      onClose(); // 直接 onClose を呼び出す
    }, duration);
    return () => clearTimeout(timer);
  }
}, [isVisible, duration]);

// 問題:アニメーション完了前にコンポーネントが消える

// ✅ 解決策:アニメーション完了後に非表示にする
const handleClose = () => {
  setIsAnimating(false);
  setTimeout(() => {
    onClose();
  }, 300); // アニメーション時間分待つ
};

複数通知の管理システム

通知スタックの実装

複数の通知を同時に管理できるシステムを作成しましょう。

typescript// components/ToastContainer.tsx
import React from 'react';
import AnimatedToast from './AnimatedToast';

interface Toast {
  id: string;
  type: 'success' | 'error' | 'warning' | 'info';
  message: string;
  duration?: number;
}

interface ToastContainerProps {
  toasts: Toast[];
  onRemove: (id: string) => void;
}

const ToastContainer: React.FC<ToastContainerProps> = ({
  toasts,
  onRemove,
}) => {
  return (
    <div className='fixed top-4 right-4 z-50 space-y-2'>
      {toasts.map((toast, index) => (
        <div
          key={toast.id}
          className='transform transition-all duration-300'
          style={{
            transform: `translateY(${index * 80}px)`,
          }}
        >
          <AnimatedToast
            type={toast.type}
            message={toast.message}
            duration={toast.duration}
            isVisible={true}
            onClose={() => onRemove(toast.id)}
          />
        </div>
      ))}
    </div>
  );
};

export default ToastContainer;

通知管理用のカスタムフック

typescript// hooks/useToastManager.ts
import { useState, useCallback } from 'react';

interface Toast {
  id: string;
  type: 'success' | 'error' | 'warning' | 'info';
  message: string;
  duration?: number;
}

export const useToastManager = () => {
  const [toasts, setToasts] = useState<Toast[]>([]);

  const addToast = useCallback(
    (
      type: 'success' | 'error' | 'warning' | 'info',
      message: string,
      duration = 5000
    ) => {
      const id = Date.now().toString();
      const newToast: Toast = {
        id,
        type,
        message,
        duration,
      };

      setToasts((prev) => [...prev, newToast]);

      // 自動削除
      setTimeout(() => {
        removeToast(id);
      }, duration);
    },
    []
  );

  const removeToast = useCallback((id: string) => {
    setToasts((prev) =>
      prev.filter((toast) => toast.id !== id)
    );
  }, []);

  const clearAllToasts = useCallback(() => {
    setToasts([]);
  }, []);

  return {
    toasts,
    addToast,
    removeToast,
    clearAllToasts,
  };
};

使用例とエラーハンドリング

typescript// pages/index.tsx
import React from 'react';
import ToastContainer from '../components/ToastContainer';
import { useToastManager } from '../hooks/useToastManager';

const App: React.FC = () => {
  const { toasts, addToast, removeToast, clearAllToasts } =
    useToastManager();

  const handleSuccess = () => {
    addToast('success', 'データの保存が完了しました!');
  };

  const handleError = () => {
    addToast('error', 'ネットワークエラーが発生しました');
  };

  const handleWarning = () => {
    addToast('warning', '保存されていない変更があります');
  };

  return (
    <div className='min-h-screen bg-gray-100 p-8'>
      <div className='max-w-md mx-auto space-y-4'>
        <button
          onClick={handleSuccess}
          className='w-full bg-green-500 text-white py-2 px-4 rounded hover:bg-green-600'
        >
          成功通知を追加
        </button>

        <button
          onClick={handleError}
          className='w-full bg-red-500 text-white py-2 px-4 rounded hover:bg-red-600'
        >
          エラー通知を追加
        </button>

        <button
          onClick={handleWarning}
          className='w-full bg-yellow-500 text-white py-2 px-4 rounded hover:bg-yellow-600'
        >
          警告通知を追加
        </button>

        <button
          onClick={clearAllToasts}
          className='w-full bg-gray-500 text-white py-2 px-4 rounded hover:bg-gray-600'
        >
          すべての通知をクリア
        </button>
      </div>

      <ToastContainer
        toasts={toasts}
        onRemove={removeToast}
      />
    </div>
  );
};

export default App;

複数通知管理でのよくあるエラー

typescript// ❌ エラー:メモリリークの原因
useEffect(() => {
  const timer = setTimeout(() => {
    removeToast(id);
  }, duration);

  // クリーンアップ関数がない
}, [duration]);

// 問題:コンポーネントがアンマウントされてもタイマーが残る

// ✅ 解決策:クリーンアップ関数を追加
useEffect(() => {
  const timer = setTimeout(() => {
    removeToast(id);
  }, duration);

  return () => clearTimeout(timer);
}, [duration, id, removeToast]);

カスタマイズ可能なテーマ設計

テーマシステムの実装

プロジェクト全体で一貫した通知スタイルを維持するためのテーマシステムを作成しましょう。

typescript// types/notification.ts
export interface NotificationTheme {
  success: {
    background: string;
    text: string;
    border: string;
    icon: string;
  };
  error: {
    background: string;
    text: string;
    border: string;
    icon: string;
  };
  warning: {
    background: string;
    text: string;
    border: string;
    icon: string;
  };
  info: {
    background: string;
    text: string;
    border: string;
    icon: string;
  };
}

export interface NotificationConfig {
  position:
    | 'top-right'
    | 'top-left'
    | 'bottom-right'
    | 'bottom-left'
    | 'top-center'
    | 'bottom-center';
  maxToasts: number;
  autoClose: boolean;
  autoCloseDelay: number;
  theme: NotificationTheme;
}

デフォルトテーマの定義

typescript// themes/defaultTheme.ts
import { NotificationTheme } from '../types/notification';

export const defaultTheme: NotificationTheme = {
  success: {
    background: 'bg-green-50',
    text: 'text-green-800',
    border: 'border-green-500',
    icon: 'text-green-500',
  },
  error: {
    background: 'bg-red-50',
    text: 'text-red-800',
    border: 'border-red-500',
    icon: 'text-red-500',
  },
  warning: {
    background: 'bg-yellow-50',
    text: 'text-yellow-800',
    border: 'border-yellow-500',
    icon: 'text-yellow-500',
  },
  info: {
    background: 'bg-blue-50',
    text: 'text-blue-800',
    border: 'border-blue-500',
    icon: 'text-blue-500',
  },
};

export const darkTheme: NotificationTheme = {
  success: {
    background: 'bg-green-900',
    text: 'text-green-100',
    border: 'border-green-400',
    icon: 'text-green-400',
  },
  error: {
    background: 'bg-red-900',
    text: 'text-red-100',
    border: 'border-red-400',
    icon: 'text-red-400',
  },
  warning: {
    background: 'bg-yellow-900',
    text: 'text-yellow-100',
    border: 'border-yellow-400',
    icon: 'text-yellow-400',
  },
  info: {
    background: 'bg-blue-900',
    text: 'text-blue-100',
    border: 'border-blue-400',
    icon: 'text-blue-400',
  },
};

テーマ対応の通知コンポーネント

typescript// components/ThemedNotification.tsx
import React from 'react';
import { NotificationTheme } from '../types/notification';

interface ThemedNotificationProps {
  type: 'success' | 'error' | 'warning' | 'info';
  message: string;
  theme: NotificationTheme;
  isVisible: boolean;
  onClose: () => void;
}

const ThemedNotification: React.FC<
  ThemedNotificationProps
> = ({ type, message, theme, isVisible, onClose }) => {
  if (!isVisible) return null;

  const currentTheme = theme[type];

  return (
    <div
      className={`
      fixed top-4 right-4 z-50 p-4 rounded-lg shadow-lg border-l-4
      ${currentTheme.background} ${currentTheme.text} ${
        currentTheme.border
      }
      transform transition-all duration-300 ease-out
      ${
        isVisible
          ? 'translate-x-0 opacity-100'
          : 'translate-x-full opacity-0'
      }
    `}
    >
      <div className='flex items-center justify-between'>
        <div className='flex items-center space-x-3'>
          <div className={currentTheme.icon}>
            {type === 'success' && (
              <svg
                className='w-5 h-5'
                fill='currentColor'
                viewBox='0 0 20 20'
              >
                <path
                  fillRule='evenodd'
                  d='M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
                  clipRule='evenodd'
                />
              </svg>
            )}
            {type === 'error' && (
              <svg
                className='w-5 h-5'
                fill='currentColor'
                viewBox='0 0 20 20'
              >
                <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>
          <span className='text-sm font-medium'>
            {message}
          </span>
        </div>
        <button
          onClick={onClose}
          className='ml-4 text-gray-400 hover:text-gray-600'
        >
          <svg
            className='w-4 h-4'
            fill='none'
            stroke='currentColor'
            viewBox='0 0 24 24'
          >
            <path
              strokeLinecap='round'
              strokeLinejoin='round'
              strokeWidth={2}
              d='M6 18L18 6M6 6l12 12'
            />
          </svg>
        </button>
      </div>
    </div>
  );
};

export default ThemedNotification;

テーマ関連のよくあるエラー

typescript// ❌ エラー:テーマの型安全性が不十分
const getThemeStyles = (type: string, theme: any) => {
  return theme[type]; // 型チェックがない
};

// 問題:存在しないテーマプロパティにアクセスする可能性

// ✅ 解決策:型安全なテーマアクセス
const getThemeStyles = (
  type: keyof NotificationTheme,
  theme: NotificationTheme
) => {
  return theme[type];
};

アクセシビリティ対応

ARIA 属性の追加

アクセシビリティを考慮した通知コンポーネントを作成しましょう。

typescript// components/AccessibleNotification.tsx
import React, { useEffect, useRef } from 'react';

interface AccessibleNotificationProps {
  type: 'success' | 'error' | 'warning' | 'info';
  message: string;
  isVisible: boolean;
  onClose: () => void;
  autoFocus?: boolean;
}

const AccessibleNotification: React.FC<
  AccessibleNotificationProps
> = ({
  type,
  message,
  isVisible,
  onClose,
  autoFocus = true,
}) => {
  const notificationRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (isVisible && autoFocus && notificationRef.current) {
      notificationRef.current.focus();
    }
  }, [isVisible, autoFocus]);

  const getRole = () => {
    switch (type) {
      case 'error':
        return 'alert';
      case 'warning':
        return 'alert';
      default:
        return 'status';
    }
  };

  const getAriaLive = () => {
    switch (type) {
      case 'error':
        return 'assertive';
      case 'warning':
        return 'assertive';
      default:
        return 'polite';
    }
  };

  if (!isVisible) return null;

  return (
    <div
      ref={notificationRef}
      role={getRole()}
      aria-live={getAriaLive()}
      aria-atomic='true'
      tabIndex={-1}
      className={`
        fixed top-4 right-4 z-50 p-4 rounded-lg shadow-lg border-l-4
        focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
        ${
          type === 'success'
            ? 'bg-green-50 border-green-500 text-green-800'
            : ''
        }
        ${
          type === 'error'
            ? 'bg-red-50 border-red-500 text-red-800'
            : ''
        }
        ${
          type === 'warning'
            ? 'bg-yellow-50 border-yellow-500 text-yellow-800'
            : ''
        }
        ${
          type === 'info'
            ? 'bg-blue-50 border-blue-500 text-blue-800'
            : ''
        }
      `}
    >
      <div className='flex items-center justify-between'>
        <div className='flex items-center space-x-3'>
          <span className='text-sm font-medium'>
            {message}
          </span>
        </div>
        <button
          onClick={onClose}
          aria-label='通知を閉じる'
          className='ml-4 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 rounded'
        >
          <svg
            className='w-4 h-4'
            fill='none'
            stroke='currentColor'
            viewBox='0 0 24 24'
          >
            <path
              strokeLinecap='round'
              strokeLinejoin='round'
              strokeWidth={2}
              d='M6 18L18 6M6 6l12 12'
            />
          </svg>
        </button>
      </div>
    </div>
  );
};

export default AccessibleNotification;

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

typescript// hooks/useKeyboardNavigation.ts
import { useEffect } from 'react';

export const useKeyboardNavigation = (
  isVisible: boolean,
  onClose: () => void
) => {
  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (!isVisible) return;

      switch (event.key) {
        case 'Escape':
          event.preventDefault();
          onClose();
          break;
        case 'Enter':
          event.preventDefault();
          onClose();
          break;
      }
    };

    if (isVisible) {
      document.addEventListener('keydown', handleKeyDown);
    }

    return () => {
      document.removeEventListener(
        'keydown',
        handleKeyDown
      );
    };
  }, [isVisible, onClose]);
};

スクリーンリーダー対応

typescript// components/ScreenReaderAnnouncement.tsx
import React, { useEffect } from 'react';

interface ScreenReaderAnnouncementProps {
  message: string;
  isVisible: boolean;
  priority?: 'polite' | 'assertive';
}

const ScreenReaderAnnouncement: React.FC<
  ScreenReaderAnnouncementProps
> = ({ message, isVisible, priority = 'polite' }) => {
  useEffect(() => {
    if (isVisible && message) {
      // スクリーンリーダーにメッセージを読み上げさせる
      const announcement = document.createElement('div');
      announcement.setAttribute('aria-live', priority);
      announcement.setAttribute('aria-atomic', 'true');
      announcement.className = 'sr-only';
      announcement.textContent = message;

      document.body.appendChild(announcement);

      // 少し待ってから削除
      setTimeout(() => {
        document.body.removeChild(announcement);
      }, 1000);
    }
  }, [isVisible, message, priority]);

  return null;
};

export default ScreenReaderAnnouncement;

アクセシビリティ関連のよくあるエラー

typescript// ❌ エラー:アクセシビリティが考慮されていない
<div className="notification">
  <span>{message}</span>
  <button onClick={onClose}>×</button>
</div>

// 問題:
// - スクリーンリーダーが通知の存在を認識できない
// - キーボードナビゲーションができない
// - ボタンに適切なラベルがない

// ✅ 解決策:アクセシビリティを考慮した実装
<div
  role="alert"
  aria-live="assertive"
  aria-atomic="true"
  tabIndex={-1}
  className="notification"
>
  <span>{message}</span>
  <button
    onClick={onClose}
    aria-label="通知を閉じる"
    onKeyDown={(e) => e.key === 'Enter' && onClose()}
  >
    ×
  </button>
</div>

まとめ

この記事では、Tailwind CSS を使用した通知・トースト UI の実装について、段階的に学んできました。基本的な通知コンポーネントから始まり、アニメーション、複数通知の管理、テーマシステム、そしてアクセシビリティ対応まで、実用的な実装例を紹介しました。

重要なポイント

  1. 段階的な実装: シンプルなものから始めて、徐々に機能を追加していくことで、理解しやすく保守性の高いコードを書けます。

  2. エラーハンドリング: 実際の開発で遭遇する可能性の高いエラーとその解決策を事前に知っておくことで、開発効率が大幅に向上します。

  3. アクセシビリティ: すべてのユーザーが快適に使えるアプリケーションを作るためには、アクセシビリティを最初から考慮した設計が重要です。

  4. 再利用性: カスタムフックやテーマシステムを活用することで、プロジェクト全体で一貫した通知システムを実現できます。

次のステップ

この記事で学んだ内容を基に、あなたのプロジェクトに通知システムを実装してみてください。最初はシンプルなものから始めて、徐々に機能を追加していくことをお勧めします。

また、実際のユーザーフィードバックを収集して、通知の表示タイミングやメッセージの内容を改善していくことで、より良いユーザー体験を提供できるようになります。

通知システムは、アプリケーションの使いやすさを大きく左右する要素です。適切に実装することで、ユーザーが安心してアプリケーションを使用できる環境を作り出せます。

関連リンク