/**
* Pagination Component
*
* Professional pagination with smart ellipsis logic and minimal design.
* Handles large page counts gracefully and provides smooth interactions.
*/
"use client";
import React from "react";
import { cn } from "@/lib/cn";
export interface PaginationProps {
/** Current active page (1-based) */
currentPage: number;
/** Total number of pages */
totalPages: number;
/** Callback when page is changed */
onPageChange: (page: number) = void;
/** Whether to show UI when only 1 page exists (default: true) */
showWhenSinglePage?: boolean;
/** Extra container className */
className?: string;
/** Max buttons to show before using ellipses (default: 7) */
maxVisible?: number;
}
export function Pagination({
currentPage,
totalPages,
onPageChange,
showWhenSinglePage = true,
className = "",
maxVisible = 7,
}: PaginationProps) {
const current = Math.min(Math.max(1, currentPage), Math.max(1, totalPages));
const total = Math.max(1, totalPages);
if (!showWhenSinglePage && total = 1) return null;
// Logic to calculate which page numbers to show
const getPageNumbers = () = {
const pages: (number string)[] = [];
if (total = maxVisible) {
for (let i = 1; i = total; i++) pages.push(i);
} else {
// Always show first page
pages.push(1);
const start = Math.max(2, current - 2);
const end = Math.min(total - 1, current + 2);
if (start 2) pages.push("...");
for (let i = start; i = end; i++) {
pages.push(i);
}
if (end total - 1) pages.push("...");
// Always show last page
pages.push(total);
}
return pages;
};
const pageNumbers = getPageNumbers();
return (
nav
className={cn("flex items-center justify-center gap-1.5 select-none", className)}
aria-label="Pagination Navigation"
{/* Previous Button */}
button
type="button"
onClick={() = onPageChange(current - 1)}
disabled={current = 1}
className={cn(
"flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-200",
"border border-slate-200 bg-white text-slate-600 hover:border-slate-900 hover:text-slate-900 hover:bg-slate-50",
"disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:border-slate-200 disabled:hover:bg-white"
)}
aria-label="Go to previous page"
svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
path d="M15 18l-6-6 6-6" /
/svg
/button
{/* Page Numbers */}
div className="flex items-center gap-1.5"
{pageNumbers.map((p, idx) = {
if (p === "...") {
return (
span key={dots-${idx}} className="w-10 h-10 flex items-center justify-center text-slate-400 font-medium"
/span
);
}
const isActive = p === current;
return (
button
key={p}
type="button"
onClick={() = onPageChange(p as number)}
aria-current={isActive ? "page" : undefined}
className={cn(
"relative flex items-center justify-center w-10 h-10 rounded-xl text-sm font-bold transition-all duration-300",
isActive
? "bg-slate-900 text-white shadow-lg shadow-slate-900/20 scale-105 z-10"
: "text-slate-500 hover:bg-slate-100 hover:text-slate-900 border border-transparent hover:border-slate-200"
)}
{p}
{isActive && (
span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-white opacity-50" /
)}
/button
);
})}
/div
{/* Next Button */}
button
type="button"
onClick={() = onPageChange(current + 1)}
disabled={current = total}
className={cn(
"flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-200",
"border border-slate-200 bg-white text-slate-600 hover:border-slate-900 hover:text-slate-900 hover:bg-slate-50",
"disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:border-slate-200 disabled:hover:bg-white"
)}
aria-label="Go to next page"
svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
path d="M9 18l6-6-6-6" /
/svg
/button
/nav
);
}
1. 왜 Pagination을 따로 만들었는지
블로그유튜브 목록, 관리자 미디어연락처글 목록처럼 페이지네이션이 필요한 곳이 많았는데, 매번 이전/다음 + 페이지 번호 + 말줄임 로직을 반복하기 부담됐습니다. 그래서 현재 페이지총 페이지변경 콜백만 넘기면 번호와 말줄임을 알아서 계산하는 공용 Pagination을 만들기로 했습니다. 1-based로 통일하고, 총 페이지가 1일 때 숨길지 말지 선택할 수 있게 했습니다.
2. 컴포넌트 구성과 설계 방향
페이지 번호 계산getPageNumbers()에서 (number "...")[]를 만듭니다. total = maxVisible이면 1부터 total까지 전부 넣고, 그렇지 않으면 첫 페이지(1) + 말줄임(필요 시) + 현재 주변 구간(current-2 ~ current+2) + 말줄임(필요 시) + 마지막 페이지(total) 순으로 넣었습니다. 항상 1과 마지막을 보여주어서 끝으로 한 번에 이동할 수 있게 했고, 중간은 현재 기준으로만 노출해 버튼 수를 maxVisible 이하로 유지했습니다.
경계 값 처리currentPagetotalPages가 API나 쿼리에서 어긋날 수 있어서, 렌더 전에 current는 Math.min(Math.max(1, currentPage), Math.max(1, totalPages)), total은 Math.max(1, totalPages)로 클램프했습니다. 이렇게 해서 0 이하나 total 초과 값이 들어와도 깨지지 않고, total이 0이어도 1페이지로 취급해 버튼이 비정상적으로 나오지 않게 했습니다.
접근성
컨테이너는 nav aria-label="Pagination Navigation"로 두어 페이지 이동 영역임을 알렸고, 이전/다음 버튼에는 aria-label="Go to previous page" / "Go to next page"를 넣었습니다. 현재 페이지 버튼에는 aria-current="page"만 넣어서 스크린 리더가 현재 페이지로 읽게 했습니다. 말줄임은 클릭하지 않는 span에 만 넣어서 더 많은 페이지가 있다는 시각적 힌트만 주고, 포커스는 번호이전/다음에만 가도록 했습니다.
스타일
이전/다음은 동일한 스타일로 두고, 첫/끝 페이지에서만 disabled로 막았습니다. 페이지 번호는 비활성은 테두리 없는 호버 스타일, 활성은 bg-slate-900, scale-105, 그림자, 아래 작은 점으로 강조해 지금 이 페이지가 한눈에 보이게 했습니다. "use client"는 이 컴포넌트를 쓰는 쪽이 대부분 클라이언트에서 페이지 상태를 갖기 때문에 붙여 두었고, 내부에는 훅이 없어 서버에서 렌더해도 동작합니다.
3. 프롭스가 필요한 이유
currentPage
현재 몇 페이지인지 알려주기 위해 넣었습니다. 1-based로 통일해서 1이 첫 페이지로 맞추고, 위에서 클램프한 current로만 렌더해 잘못된 값이 와도 안전하게 했습니다.
totalPages
총 페이지 수를 알려주기 위해 넣었습니다. 목록 API에서 total_pages나 totalPages를 받아 그대로 넘기면 되고, 0 이하는 내부에서 1로 취급해 아무 버튼도 안 나오는 상황을 막았습니다.
onPageChange
페이지를 바꿀 때 부모가 URL상태를 갱신하려면 필요해서 넣었습니다. 이전/다음/번호 클릭 시 모두 onPageChange(페이지 번호)만 호출하고, 실제 쿼리리패치라우팅은 부모에서 처리하게 했습니다.
showWhenSinglePage
총 페이지가 1일 때 UI를 숨길지 말지 정하려고 넣었습니다. 기본은 true라서 한 페이지만 있어도 페이지네이션 영역이 있다는 걸 보여줄 수 있고, false면 total = 1일 때 null을 반환해 목록 하단을 비우고 싶을 때 씁니다. 블로그유튜브 공개 목록에서는 보통 false, 관리자 목록에서는 true로 두는 식으로 쓰고 있습니다.
maxVisible
한 번에 보여줄 번호 버튼 개수 상한을 두려고 넣었습니다. 기본 7이면 1 4 5 6 10 같은 형태가 되고, 이보다 크면 말줄임으로 중간을 생략합니다. 페이지가 매우 많을 때 버튼이 넘치지 않게 하려고 이렇게 했습니다.
className
네비를 감싸는 nav에만 적용되게 해 두었습니다. 상단/하단 여백, 정렬, 그리드 안에서의 위치 등을 호출하는 쪽에서 조정할 수 있게 하려고 넣었습니다.
4. 사용 용도와 쓰는 방식
관리자 목록: 미디어블로그 글연락처유튜브 목록에서
currentPage는pagination.page,totalPages는 API의totalPages/total_pages를Math.max(1, ...)로 넘기고,onPageChange에서 쿼리 파라미터를 바꾼 뒤 데이터를 다시 불러옵니다. 한 페이지만 있어도 페이지네이션을 보여주고 싶으면showWhenSinglePage를 true로 둡니다.공개 목록: 블로그 포스트 목록유튜브 영상 그리드에서는 URL의
page를 현재 페이지로 쓰고,totalPages는 서버/API에서 계산한 값을 넘깁니다.onPageChange에서setCurrentPage또는 라우터로?page=n을 바꾸고, 한 페이지만 있을 때는 숨기려고showWhenSinglePage={false}를 많이 씁니다.쇼케이스: 한 페이지만 있을 때 보이기/숨기기, 여러 페이지에서 현재 페이지 변경 예시를 같은 컴포넌트로 보여줄 때 사용합니다.
5. 정리
Pagination은 현재 페이지총 페이지만 넘기면 번호와 말줄임을 알아서 계산하고, 이전/다음과 접근성을 한 번에 처리하자는 목적으로 만들었습니다. currentPagetotalPagesonPageChange는 제어를 위해, showWhenSinglePage는 1페이지일 때 UI 노출 여부를 위해, maxVisible은 말줄임 기준을 위해, className은 레이아웃 조정을 위해 넣었습니다. 블로그유튜브 공개/관리자 목록, 미디어연락처글 목록에서 공통으로 쓰이고 있습니다.