モダンUIコンポーネントの実装パターン
実装ガイドReact、Vue、Angularで共通して使えるUIパターンを解説
localStorage連携でお気に入り状態を永続化する高機能ボタン。アニメーション、音響・触覚フィードバック、統計機能付き。
お気に入り状態とカウントをlocalStorageに自動保存し、ページリロード後も状態を維持
スムーズアニメーション、音響フィードバック、触覚フィードバックでユーザー体験を向上
7つのテーマ、4つのバリアント、5つのサイズ、形状変更など幅広いデザインオプション
プロンプト例:
お気に入りボタンにソーシャル機能を追加してください。お気に入り数のリアルタイム同期、ユーザー間での共有機能、人気アイテムランキング、おすすめ機能、フォロー中ユーザーのお気に入り活動フィードを実装してください。
プロンプト例:
お気に入りデータをクラウドと同期する機能を開発してください。オフライン対応、競合解決アルゴリズム、複数デバイス間での即座の同期、バックアップ・復元機能、データエクスポート・インポート機能を含めてください。
プロンプト例:
ユーザーの行動パターンを学習してパーソナライズされたお気に入り体験を提供してください。興味関心の自動分析、関連アイテムの提案、最適なタイミングでの通知、お気に入り整理の自動化、趣味嗜好マッチングを実装してください。
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>
);
}