モダンUIコンポーネントの実装パターン
実装ガイドReact、Vue、Angularで共通して使えるUIパターンを解説
大量のコンテンツを複数のページに分割して表示するナビゲーションコンポーネント
多数のページでも見やすい省略記号による表示調整
表示範囲、ボタンの有無、サイズなど柔軟な設定
適切なARIA属性とキーボードナビゲーション対応
プロンプト例:
ページネーションと無限スクロールを組み合わせたハイブリッドナビゲーションを実装してください。スクロール位置の記憶、プログレス表示、パフォーマンス最適化、SEO対応を含めてください。
プロンプト例:
ユーザーの行動を学習して最適なページサイズを自動調整するページネーションシステムを作成してください。読み込み速度、画面サイズ、ユーザーの閲覧パターンを考慮してください。
プロンプト例:
キーボードショートカット、音声コマンド、ジェスチャー操作に対応した次世代ページネーションを実装してください。プレビュー機能、ブックマーク、履歴管理も含めてください。
import React, { useMemo } from 'react';
import type { BasePaginationProps } from '../types';
interface PaginationProps extends BasePaginationProps {
className?: string;
}
export const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange,
size = 'md',
showFirstLast = true,
showPrevNext = true,
siblings = 1,
boundaries = 1,
className = '',
}) => {
// ページ番号の配列を生成
const pageNumbers = useMemo(() => {
const pages: (number | string)[] = [];
// 総ページ数が少ない場合は全て表示
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
// 境界ページ
const leftBoundary = boundaries;
const rightBoundary = totalPages - boundaries + 1;
// 現在のページの周辺
const leftSibling = Math.max(currentPage - siblings, leftBoundary + 2);
const rightSibling = Math.min(currentPage + siblings, rightBoundary - 2);
// 左側の省略記号を表示するか
const showLeftEllipsis = leftSibling > leftBoundary + 2;
// 右側の省略記号を表示するか
const showRightEllipsis = rightSibling < rightBoundary - 2;
// 左境界
for (let i = 1; i <= leftBoundary; i++) {
pages.push(i);
}
// 左省略記号
if (showLeftEllipsis) {
pages.push('left-ellipsis');
}
// 中央のページ
for (let i = leftSibling; i <= rightSibling; i++) {
if (i > leftBoundary && i < rightBoundary) {
pages.push(i);
}
}
// 右省略記号
if (showRightEllipsis) {
pages.push('right-ellipsis');
}
// 右境界
for (let i = rightBoundary; i <= totalPages; i++) {
pages.push(i);
}
return pages;
}, [currentPage, totalPages, siblings, boundaries]);
// サイズに基づくスタイル
const getSizeClasses = () => {
const sizes = {
sm: 'text-sm min-w-8 h-8 px-2',
md: 'text-base min-w-10 h-10 px-3',
lg: 'text-lg min-w-12 h-12 px-4',
};
return sizes[size as keyof typeof sizes] || sizes.md;
};
const sizeClasses = getSizeClasses();
const iconSizeClasses = size === 'sm' ? 'w-4 h-4' : size === 'lg' ? 'w-6 h-6' : 'w-5 h-5';
// ページボタンのレンダリング
const renderPageButton = (page: number | string, index: number) => {
if (typeof page === 'string') {
return (
<span
key={`${page}-${index}`}
className={`inline-flex items-center justify-center ${sizeClasses} text-gray-400 cursor-default`}
>
...
</span>
);
}
const isActive = page === currentPage;
return (
<button
key={page}
onClick={() => onPageChange(page)}
disabled={isActive}
aria-current={isActive ? 'page' : undefined}
className={`
inline-flex items-center justify-center font-medium rounded-lg
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2
${sizeClasses}
${
isActive
? 'bg-blue-600 text-white cursor-default'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300 focus:ring-blue-500'
}
`}
>
{page}
</button>
);
};
return (
<nav
className={`flex items-center justify-center ${className}`}
aria-label="ページネーション"
>
<ul className="flex items-center gap-1">
{/* 最初のページへ */}
{showFirstLast && (
<li>
<button
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
aria-label="最初のページへ"
className={`
inline-flex items-center justify-center font-medium rounded-lg
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2
${sizeClasses}
${
currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300 focus:ring-blue-500'
}
`}
>
<svg className={iconSizeClasses} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
</li>
)}
{/* 前のページへ */}
{showPrevNext && (
<li>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="前のページへ"
className={`
inline-flex items-center justify-center font-medium rounded-lg
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2
${sizeClasses}
${
currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300 focus:ring-blue-500'
}
`}
>
<svg className={iconSizeClasses} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
</li>
)}
{/* ページ番号 */}
{pageNumbers.map((page, index) => (
<li key={`${page}-${index}`}>
{renderPageButton(page, index)}
</li>
))}
{/* 次のページへ */}
{showPrevNext && (
<li>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="次のページへ"
className={`
inline-flex items-center justify-center font-medium rounded-lg
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2
${sizeClasses}
${
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300 focus:ring-blue-500'
}
`}
>
<svg className={iconSizeClasses} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</li>
)}
{/* 最後のページへ */}
{showFirstLast && (
<li>
<button
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
aria-label="最後のページへ"
className={`
inline-flex items-center justify-center font-medium rounded-lg
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2
${sizeClasses}
${
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300 focus:ring-blue-500'
}
`}
>
<svg className={iconSizeClasses} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
</li>
)}
</ul>
</nav>
);
};
// シンプルなページネーション(前へ/次へのみ)
export const SimplePagination: React.FC<{
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
className?: string;
}> = ({ currentPage, totalPages, onPageChange, className = '' }) => {
return (
<div className={`flex items-center justify-between ${className}`}>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className={`
inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2
${
currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300 focus:ring-blue-500'
}
`}
>
前へ
</button>
<span className="text-sm text-gray-700">
{currentPage} / {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={`
inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2
${
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300 focus:ring-blue-500'
}
`}
>
次へ
</button>
</div>
);
};
// ページネーションのデモコンポーネント
export const PaginationDemo: React.FC = () => {
const [page1, setPage1] = React.useState(1);
const [page2, setPage2] = React.useState(5);
const [page3, setPage3] = React.useState(1);
const [page4, setPage4] = React.useState(1);
return (
<div className="space-y-12">
{/* 基本的なページネーション */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">基本的なページネーション</h3>
<Pagination
currentPage={page1}
totalPages={10}
onPageChange={setPage1}
/>
</div>
{/* 多数のページ */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">多数のページ(省略記号あり)</h3>
<Pagination
currentPage={page2}
totalPages={50}
onPageChange={setPage2}
/>
</div>
{/* サイズバリエーション */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">サイズバリエーション</h3>
<div className="space-y-4">
<Pagination
currentPage={1}
totalPages={10}
onPageChange={() => {}}
size="sm"
/>
<Pagination
currentPage={1}
totalPages={10}
onPageChange={() => {}}
size="md"
/>
<Pagination
currentPage={1}
totalPages={10}
onPageChange={() => {}}
size="lg"
/>
</div>
</div>
{/* カスタマイズオプション */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">カスタマイズオプション</h3>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600 mb-2">最初/最後ボタンなし</p>
<Pagination
currentPage={page3}
totalPages={10}
onPageChange={setPage3}
showFirstLast={false}
/>
</div>
<div>
<p className="text-sm text-gray-600 mb-2">前/次ボタンなし</p>
<Pagination
currentPage={page3}
totalPages={10}
onPageChange={setPage3}
showPrevNext={false}
/>
</div>
<div>
<p className="text-sm text-gray-600 mb-2">表示範囲を拡大</p>
<Pagination
currentPage={page3}
totalPages={20}
onPageChange={setPage3}
siblings={2}
boundaries={2}
/>
</div>
</div>
</div>
{/* シンプルなページネーション */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">シンプルなページネーション</h3>
<div className="max-w-sm">
<SimplePagination
currentPage={page4}
totalPages={10}
onPageChange={setPage4}
/>
</div>
</div>
</div>
);
};
export default Pagination;
// 基本的な使用例
import { Pagination, SimplePagination } from './pagination';
function App() {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = 50;
const itemsPerPage = 10;
const totalItems = 500;
return (
<div>
{/* 基本的なページネーション */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
{/* サイズ変更 */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
size="sm"
/>
{/* カスタマイズオプション */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
showFirstLast={false}
showPrevNext={true}
siblings={2}
boundaries={1}
/>
{/* シンプルなページネーション */}
<SimplePagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
{/* 表示情報付き */}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-700">
全{totalItems}件中 {(currentPage - 1) * itemsPerPage + 1}-
{Math.min(currentPage * itemsPerPage, totalItems)}件を表示
</p>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
</div>
);
}