お気に入りボタン - Vibe Coding Showcase

お気に入りボタン

localStorage連携でお気に入り状態を永続化する高機能ボタン。アニメーション、音響・触覚フィードバック、統計機能付き。

デザインプレビュー

基本的なお気に入りボタン

カラーテーマ

Red
Pink
Purple
Blue
Green
Yellow

スタイルバリアント

Ghost
Outline
Solid
Subtle

サイズバリエーション

XS
SM
MD
LG
XL

形状バリエーション

Square
Rounded
Circle

ラベル&カウント付き

カスタムアイコン

統計表示

お気に入り: 8件

お気に入りボタンの特徴

localStorage永続化

お気に入り状態とカウントをlocalStorageに自動保存し、ページリロード後も状態を維持

リッチフィードバック

スムーズアニメーション、音響フィードバック、触覚フィードバックでユーザー体験を向上

豊富なカスタマイズ

7つのテーマ、4つのバリアント、5つのサイズ、形状変更など幅広いデザインオプション

AI活用ガイド

ソーシャル機能拡張

プロンプト例:

お気に入りボタンにソーシャル機能を追加してください。お気に入り数のリアルタイム同期、ユーザー間での共有機能、人気アイテムランキング、おすすめ機能、フォロー中ユーザーのお気に入り活動フィードを実装してください。

マルチプラットフォーム同期

プロンプト例:

お気に入りデータをクラウドと同期する機能を開発してください。オフライン対応、競合解決アルゴリズム、複数デバイス間での即座の同期、バックアップ・復元機能、データエクスポート・インポート機能を含めてください。

AIパーソナライゼーション

プロンプト例:

ユーザーの行動パターンを学習してパーソナライズされたお気に入り体験を提供してください。興味関心の自動分析、関連アイテムの提案、最適なタイミングでの通知、お気に入り整理の自動化、趣味嗜好マッチングを実装してください。

実装コード

ファイルサイズ: 12.5KB TypeScript

コンポーネント実装

import React, { useState, useEffect, useCallback, useMemo } from 'react';

/**
 * Favorite Button - localStorage対応お気に入りボタン
 * 
 * お気に入り状態をlocalStorageに永続化し、アイコンの切り替えとアニメーションを提供する
 * 高度にカスタマイズ可能で、パフォーマンスも最適化されたコンポーネント
 */

export interface FavoriteButtonProps {
  // 必須プロップス
  /** アイテムの一意識別子 */
  itemId: string;
  
  // 基本設定
  /** ボタンのサイズ */
  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
  /** カラーテーマ */
  theme?: 'default' | 'red' | 'pink' | 'yellow' | 'purple' | 'blue' | 'green';
  /** ボタンスタイル */
  variant?: 'solid' | 'outline' | 'ghost' | 'subtle';
  /** 形状 */
  shape?: 'square' | 'rounded' | 'circle';
  
  // 表示オプション
  /** カウント表示 */
  showCount?: boolean;
  /** 初期カウント値 */
  initialCount?: number;
  /** ツールチップテキスト */
  tooltip?: string;
  /** ラベル表示 */
  showLabel?: boolean;
  /** カスタムラベル */
  customLabel?: string;
  
  // 機能オプション
  /** アニメーション有効化 */
  animated?: boolean;
  /** 音響フィードバック */
  soundEnabled?: boolean;
  /** 触覚フィードバック(モバイル) */
  hapticEnabled?: boolean;
  /** ローカルストレージキー接頭辞 */
  storagePrefix?: string;
  
  // イベントハンドラー
  /** お気に入り状態変更時のコールバック */
  onToggle?: (isFavorite: boolean, itemId: string, count: number) => void;
  /** カウント変更時のコールバック */
  onCountChange?: (count: number, itemId: string) => void;
  
  // カスタマイズ
  /** カスタムクラス */
  className?: string;
  /** 無効化 */
  disabled?: boolean;
  /** お気に入りアイコンのカスタマイズ */
  favoriteIcon?: React.ReactNode;
  /** 未お気に入りアイコンのカスタマイズ */
  unfavoriteIcon?: React.ReactNode;
}

// テーマカラー定義
const themeColors = {
  default: {
    solid: 'bg-gray-100 hover:bg-gray-200 text-gray-700 border-gray-300',
    outline: 'border-gray-300 text-gray-700 hover:bg-gray-50',
    ghost: 'text-gray-700 hover:bg-gray-100',
    subtle: 'bg-gray-50 text-gray-700 hover:bg-gray-100',
    active: 'bg-gray-600 text-white'
  },
  red: {
    solid: 'bg-red-100 hover:bg-red-200 text-red-700 border-red-300',
    outline: 'border-red-300 text-red-700 hover:bg-red-50',
    ghost: 'text-red-700 hover:bg-red-100',
    subtle: 'bg-red-50 text-red-700 hover:bg-red-100',
    active: 'bg-red-500 text-white'
  },
  pink: {
    solid: 'bg-pink-100 hover:bg-pink-200 text-pink-700 border-pink-300',
    outline: 'border-pink-300 text-pink-700 hover:bg-pink-50',
    ghost: 'text-pink-700 hover:bg-pink-100',
    subtle: 'bg-pink-50 text-pink-700 hover:bg-pink-100',
    active: 'bg-pink-500 text-white'
  },
  yellow: {
    solid: 'bg-yellow-100 hover:bg-yellow-200 text-yellow-700 border-yellow-300',
    outline: 'border-yellow-300 text-yellow-700 hover:bg-yellow-50',
    ghost: 'text-yellow-700 hover:bg-yellow-100',
    subtle: 'bg-yellow-50 text-yellow-700 hover:bg-yellow-100',
    active: 'bg-yellow-500 text-white'
  },
  purple: {
    solid: 'bg-purple-100 hover:bg-purple-200 text-purple-700 border-purple-300',
    outline: 'border-purple-300 text-purple-700 hover:bg-purple-50',
    ghost: 'text-purple-700 hover:bg-purple-100',
    subtle: 'bg-purple-50 text-purple-700 hover:bg-purple-100',
    active: 'bg-purple-500 text-white'
  },
  blue: {
    solid: 'bg-blue-100 hover:bg-blue-200 text-blue-700 border-blue-300',
    outline: 'border-blue-300 text-blue-700 hover:bg-blue-50',
    ghost: 'text-blue-700 hover:bg-blue-100',
    subtle: 'bg-blue-50 text-blue-700 hover:bg-blue-100',
    active: 'bg-blue-500 text-white'
  },
  green: {
    solid: 'bg-green-100 hover:bg-green-200 text-green-700 border-green-300',
    outline: 'border-green-300 text-green-700 hover:bg-green-50',
    ghost: 'text-green-700 hover:bg-green-100',
    subtle: 'bg-green-50 text-green-700 hover:bg-green-100',
    active: 'bg-green-500 text-white'
  }
};

// サイズ定義
const sizes = {
  xs: {
    button: 'p-1.5',
    icon: 'w-3 h-3',
    text: 'text-xs',
    gap: 'gap-1'
  },
  sm: {
    button: 'p-2',
    icon: 'w-4 h-4',
    text: 'text-sm',
    gap: 'gap-1.5'
  },
  md: {
    button: 'p-2.5',
    icon: 'w-5 h-5',
    text: 'text-sm',
    gap: 'gap-2'
  },
  lg: {
    button: 'p-3',
    icon: 'w-6 h-6',
    text: 'text-base',
    gap: 'gap-2'
  },
  xl: {
    button: 'p-3.5',
    icon: 'w-7 h-7',
    text: 'text-lg',
    gap: 'gap-2.5'
  }
};

// 形状定義
const shapes = {
  square: 'rounded-none',
  rounded: 'rounded-lg',
  circle: 'rounded-full'
};

// デフォルトアイコン
const HeartIcon = ({ filled, className }: { filled: boolean; className?: string }) => (
  <svg 
    className={className} 
    fill={filled ? 'currentColor' : 'none'} 
    stroke="currentColor" 
    viewBox="0 0 24 24"
    strokeWidth={filled ? 0 : 2}
  >
    <path 
      strokeLinecap="round" 
      strokeLinejoin="round" 
      d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" 
    />
  </svg>
);

export default function FavoriteButton({
  itemId,
  size = 'md',
  theme = 'red',
  variant = 'ghost',
  shape = 'rounded',
  showCount = false,
  initialCount = 0,
  tooltip,
  showLabel = false,
  customLabel,
  animated = true,
  soundEnabled = false,
  hapticEnabled = true,
  storagePrefix = 'favorite',
  onToggle,
  onCountChange,
  className = '',
  disabled = false,
  favoriteIcon,
  unfavoriteIcon
}: FavoriteButtonProps) {
  
  // ローカルストレージキー
  const storageKey = `${storagePrefix}_${itemId}`;
  const countStorageKey = `${storagePrefix}_count_${itemId}`;
  
  // 状態管理
  const [isFavorite, setIsFavorite] = useState(false);
  const [count, setCount] = useState(initialCount);
  const [isAnimating, setIsAnimating] = useState(false);
  const [mounted, setMounted] = useState(false);
  
  // マウント後の初期化
  useEffect(() => {
    setMounted(true);
    
    // localStorageから状態を復元
    try {
      const savedFavorite = localStorage.getItem(storageKey);
      const savedCount = localStorage.getItem(countStorageKey);
      
      if (savedFavorite !== null) {
        setIsFavorite(savedFavorite === 'true');
      }
      
      if (savedCount !== null) {
        setCount(parseInt(savedCount, 10) || initialCount);
      }
    } catch (error) {
      console.warn('Failed to load favorite state from localStorage:', error);
    }
  }, [itemId, storageKey, countStorageKey, initialCount]);
  
  // 音響フィードバック
  const playSound = useCallback((type: 'add' | 'remove') => {
    if (!soundEnabled || typeof window === 'undefined') return;
    
    try {
      const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
      const oscillator = audioContext.createOscillator();
      const gainNode = audioContext.createGain();
      
      oscillator.connect(gainNode);
      gainNode.connect(audioContext.destination);
      
      oscillator.frequency.setValueAtTime(type === 'add' ? 800 : 400, audioContext.currentTime);
      gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
      gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.1);
      
      oscillator.start(audioContext.currentTime);
      oscillator.stop(audioContext.currentTime + 0.1);
    } catch (error) {
      console.warn('Failed to play sound:', error);
    }
  }, [soundEnabled]);
  
  // 触覚フィードバック
  const triggerHaptic = useCallback(() => {
    if (!hapticEnabled || typeof navigator === 'undefined') return;
    
    try {
      if ('vibrate' in navigator) {
        navigator.vibrate(50);
      }
    } catch (error) {
      console.warn('Failed to trigger haptic feedback:', error);
    }
  }, [hapticEnabled]);
  
  // お気に入り状態切り替え
  const toggleFavorite = useCallback((event: React.MouseEvent) => {
    event.preventDefault();
    event.stopPropagation();
    
    if (disabled) return;
    
    const newFavorite = !isFavorite;
    const newCount = newFavorite ? count + 1 : Math.max(0, count - 1);
    
    setIsFavorite(newFavorite);
    setCount(newCount);
    
    // アニメーション開始
    if (animated) {
      setIsAnimating(true);
      setTimeout(() => setIsAnimating(false), 300);
    }
    
    // フィードバック
    playSound(newFavorite ? 'add' : 'remove');
    triggerHaptic();
    
    // localStorageに保存
    try {
      localStorage.setItem(storageKey, String(newFavorite));
      localStorage.setItem(countStorageKey, String(newCount));
    } catch (error) {
      console.warn('Failed to save favorite state to localStorage:', error);
    }
    
    // コールバック実行
    onToggle?.(newFavorite, itemId, newCount);
    onCountChange?.(newCount, itemId);
  }, [
    isFavorite, 
    count, 
    disabled, 
    animated, 
    playSound, 
    triggerHaptic, 
    storageKey, 
    countStorageKey, 
    itemId, 
    onToggle, 
    onCountChange
  ]);
  
  // スタイル計算
  const buttonStyles = useMemo(() => {
    const sizeConfig = sizes[size];
    const themeConfig = themeColors[theme];
    const shapeConfig = shapes[shape];
    
    const baseClasses = [
      'inline-flex items-center justify-center',
      'font-medium transition-all duration-200',
      'focus:outline-none focus:ring-2 focus:ring-offset-1',
      'active:scale-95',
      sizeConfig.button,
      sizeConfig.gap,
      shapeConfig,
      disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
    ];
    
    if (isFavorite) {
      baseClasses.push(themeConfig.active);
    } else {
      baseClasses.push(themeConfig[variant]);
      if (variant === 'outline') {
        baseClasses.push('border-2');
      }
    }
    
    if (animated && isAnimating) {
      baseClasses.push('animate-pulse');
    }
    
    return baseClasses.join(' ');
  }, [size, theme, shape, variant, isFavorite, disabled, animated, isAnimating]);
  
  // アイコンスタイル
  const iconClasses = useMemo(() => {
    const sizeConfig = sizes[size];
    const classes = [sizeConfig.icon];
    
    if (animated && isAnimating) {
      classes.push('animate-bounce');
    }
    
    return classes.join(' ');
  }, [size, animated, isAnimating]);
  
  // テキストスタイル
  const textClasses = useMemo(() => {
    return sizes[size].text;
  }, [size]);
  
  // ラベルテキスト
  const labelText = useMemo(() => {
    if (customLabel) return customLabel;
    return isFavorite ? 'お気に入り済み' : 'お気に入りに追加';
  }, [customLabel, isFavorite]);
  
  // レンダリング前の状態(SSR対応)
  if (!mounted) {
    return (
      <button
        className={`${buttonStyles} ${className}`}
        disabled={true}
        aria-label="読み込み中..."
      >
        <HeartIcon filled={false} className={iconClasses} />
        {showLabel && <span className={textClasses}>読み込み中...</span>}
        {showCount && <span className={textClasses}>0</span>}
      </button>
    );
  }
  
  return (
    <button
      onClick={toggleFavorite}
      disabled={disabled}
      className={`${buttonStyles} ${className}`}
      aria-label={tooltip || labelText}
      title={tooltip || labelText}
      type="button"
    >
      {isFavorite && favoriteIcon ? favoriteIcon : 
       !isFavorite && unfavoriteIcon ? unfavoriteIcon :
       <HeartIcon filled={isFavorite} className={iconClasses} />
      }
      
      {showLabel && (
        <span className={textClasses}>
          {labelText}
        </span>
      )}
      
      {showCount && count > 0 && (
        <span className={`${textClasses} font-semibold`}>
          {count.toLocaleString()}
        </span>
      )}
    </button>
  );
}

// 追加のヘルパーコンポーネント

/**
 * お気に入りボタンのグループ
 */
export interface FavoriteButtonGroupProps {
  items: Array<{
    id: string;
    label?: string;
    count?: number;
  }>;
  onToggle?: (itemId: string, isFavorite: boolean) => void;
  size?: FavoriteButtonProps['size'];
  theme?: FavoriteButtonProps['theme'];
  variant?: FavoriteButtonProps['variant'];
  className?: string;
}

export function FavoriteButtonGroup({
  items,
  onToggle,
  size = 'md',
  theme = 'red',
  variant = 'ghost',
  className = ''
}: FavoriteButtonGroupProps) {
  return (
    <div className={`flex flex-wrap gap-2 ${className}`}>
      {items.map((item) => (
        <FavoriteButton
          key={item.id}
          itemId={item.id}
          size={size}
          theme={theme}
          variant={variant}
          showLabel={!!item.label}
          customLabel={item.label}
          showCount={typeof item.count === 'number'}
          initialCount={item.count || 0}
          onToggle={(isFavorite) => onToggle?.(item.id, isFavorite)}
        />
      ))}
    </div>
  );
}

/**
 * お気に入り統計表示コンポーネント
 */
export interface FavoriteStatsProps {
  storagePrefix?: string;
  className?: string;
}

export function FavoriteStats({ 
  storagePrefix = 'favorite', 
  className = '' 
}: FavoriteStatsProps) {
  const [stats, setStats] = useState({ total: 0, items: [] as string[] });
  
  useEffect(() => {
    const updateStats = () => {
      try {
        const favoriteItems: string[] = [];
        
        for (let i = 0; i < localStorage.length; i++) {
          const key = localStorage.key(i);
          if (key?.startsWith(`${storagePrefix}_`) && !key.includes('_count_')) {
            const value = localStorage.getItem(key);
            if (value === 'true') {
              const itemId = key.replace(`${storagePrefix}_`, '');
              favoriteItems.push(itemId);
            }
          }
        }
        
        setStats({
          total: favoriteItems.length,
          items: favoriteItems
        });
      } catch (error) {
        console.warn('Failed to calculate favorite stats:', error);
      }
    };
    
    updateStats();
    
    // ストレージイベントをリッスン
    const handleStorageChange = () => updateStats();
    window.addEventListener('storage', handleStorageChange);
    
    return () => {
      window.removeEventListener('storage', handleStorageChange);
    };
  }, [storagePrefix]);
  
  return (
    <div className={`text-sm text-gray-600 ${className}`}>
      お気に入り: {stats.total}件
    </div>
  );
}

使用例

// 基本的な使用例
import FavoriteButton, { FavoriteButtonGroup, FavoriteStats } from './favorite-button';

function App() {
  return (
    <div className="space-y-6">
      {/* 基本的なお気に入りボタン */}
      <FavoriteButton
        itemId="article-123"
        onToggle={(isFavorite, itemId, count) => {
          console.log(`${itemId}: ${isFavorite ? 'お気に入りに追加' : '削除'}`);
        }}
      />

      {/* カスタマイズされたボタン */}
      <FavoriteButton
        itemId="product-456"
        size="lg"
        theme="pink"
        variant="solid"
        shape="circle"
        showCount={true}
        showLabel={true}
        initialCount={42}
        animated={true}
        soundEnabled={true}
        tooltip="この商品をお気に入りに追加"
      />

      {/* 複数テーマのボタン */}
      <div className="flex gap-4">
        <FavoriteButton itemId="item-1" theme="red" />
        <FavoriteButton itemId="item-2" theme="pink" />
        <FavoriteButton itemId="item-3" theme="purple" />
        <FavoriteButton itemId="item-4" theme="blue" />
        <FavoriteButton itemId="item-5" theme="green" />
      </div>

      {/* サイズバリエーション */}
      <div className="flex items-center gap-3">
        <FavoriteButton itemId="xs" size="xs" />
        <FavoriteButton itemId="sm" size="sm" />
        <FavoriteButton itemId="md" size="md" />
        <FavoriteButton itemId="lg" size="lg" />
        <FavoriteButton itemId="xl" size="xl" />
      </div>

      {/* ボタングループ */}
      <FavoriteButtonGroup
        items={[
          { id: 'post-1', label: '投稿1', count: 15 },
          { id: 'post-2', label: '投稿2', count: 8 },
          { id: 'post-3', label: '投稿3', count: 23 }
        ]}
        size="md"
        theme="red"
        onToggle={(itemId, isFavorite) => {
          console.log(`グループアイテム ${itemId}: ${isFavorite}`);
        }}
      />

      {/* カスタムアイコン */}
      <FavoriteButton
        itemId="bookmark-1"
        favoriteIcon={
          <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
            <path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/>
          </svg>
        }
        unfavoriteIcon={
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/>
          </svg>
        }
        theme="blue"
        showLabel={true}
        customLabel="ブックマーク"
      />

      {/* 統計表示 */}
      <FavoriteStats className="mt-4 p-3 bg-gray-50 rounded-lg" />
    </div>
  );
}