Pagination
긴 목록을 페이지 단위로 이동합니다.
설치
pnpm dlx ply-ui add pagination사용법
pageCount는 전체 페이지 수이며, page는 1부터 시작합니다. onPageChange에서 표시할 데이터와 현재 페이지를 갱신합니다. 페이지가 많으면 처음·끝·현재 주변 페이지를 남기고 중간을 생략합니다.
이 컴포넌트는 페이지 선택 UI입니다. 서버 요청과 URL 변경은 사용하는 앱에서 처리합니다. 필터가 바뀌어 현재 페이지가 범위를 벗어나면 선택을 보정하고 onPageChange에 유효한 페이지를 전달합니다. 제어 방식에서는 이 콜백으로 page를 갱신합니다. 페이지가 0개면 탐색 UI를 숨깁니다.
내부에서 페이지 관리
page 없이 defaultPage를 지정하면 선택 상태를 내부에서 관리합니다.
비활성
요청 중 중복 이동을 막으려면 disabled를 지정합니다. 처음과 마지막 페이지에서는 해당 방향의 이동 버튼만 자동으로 비활성화됩니다.
접근성
현재 페이지에는 aria-current="page"를 지정합니다. Tab으로 버튼을 이동하고 Enter 또는 Space로 선택합니다. 페이지 이동 영역이 여러 개라면 각각 다른 aria-label을 지정합니다. getPageLabel로 번호 버튼의 읽기 이름을, previousLabel·nextLabel·pageInputLabel로 이동 버튼과 입력칸의 이름을 바꿀 수 있습니다. 기본 문구는 한국어입니다.
속성
속성
타입
좁은 영역에서의 동작
주어진 영역에 숫자 버튼을 모두 배치할 수 없으면 페이지 입력칸으로 전환합니다. Enter 또는 포커스 이동으로 페이지를 확정하고, Escape로 편집을 취소합니다. 반응형 예제에서 좁은 영역의 동작을 확인할 수 있습니다.
구현 코드
components/ui/pagination.tsx
"use client";
import {
useState,
useEffect,
useLayoutEffect,
useRef,
type ComponentProps,
} from "react";
import { useMergedRef } from "../../lib/use-merged-ref";
import { cx } from "../../lib/cx";
import { paginationItems } from "../../lib/pagination";
import { Button } from "./button";
import { Icon } from "./icon";
import "./pagination.css";
export type PaginationProps = Omit<ComponentProps<"nav">, "children"> & {
/** 전체 페이지 수입니다. 0이면 페이지 이동 UI를 표시하지 않습니다. */
pageCount: number;
/** 현재 페이지입니다. 1부터 시작합니다. */
page?: number;
/** 비제어 방식의 시작 페이지입니다. @defaultValue 1 */
defaultPage?: number;
onPageChange?: (page: number) => void;
/** 데이터를 불러오는 동안 이동을 막을 때 사용합니다. @defaultValue false */
disabled?: boolean;
/** 페이지 버튼의 접근 가능한 이름입니다. @defaultValue (page) => `${page}페이지` */
getPageLabel?: (page: number) => string;
/** 이전 페이지 버튼의 접근 가능한 이름입니다. @defaultValue "이전 페이지" */
previousLabel?: string;
/** 다음 페이지 버튼의 접근 가능한 이름입니다. @defaultValue "다음 페이지" */
nextLabel?: string;
/** 좁은 영역에서 표시하는 페이지 입력칸의 이름입니다. @defaultValue "이동할 페이지" */
pageInputLabel?: string;
};
/** 페이지 선택만 담당합니다. 데이터 요청과 URL 갱신은 onPageChange에서 처리합니다. */
export function Pagination({
pageCount,
page,
defaultPage = 1,
onPageChange,
disabled = false,
getPageLabel = (value) => `${value}페이지`,
previousLabel = "이전 페이지",
nextLabel = "다음 페이지",
pageInputLabel = "이동할 페이지",
className,
ref,
...props
}: PaginationProps) {
const [internalPage, setInternalPage] = useState(defaultPage);
const root = useRef<HTMLElement>(null);
const mergedRef = useMergedRef(root, ref);
const [compact, setCompact] = useState(false);
const restoreFocus = useRef(false);
const lastCorrection = useRef<string | null>(null);
const count = Number.isFinite(pageCount)
? Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(pageCount)))
: 0;
const value = page ?? internalPage;
const current = Number.isFinite(value)
? Math.max(1, Math.min(count, Math.floor(value)))
: 1;
const items = paginationItems(current, count);
// 화면 너비가 아니라 이 탐색 영역에 실제로 주어진 공간을 기준으로 바꿉니다.
useLayoutEffect(() => {
const element = root.current;
if (!element) return;
const update = () => {
const button = element.querySelector("button");
if (!button) return;
const style = getComputedStyle(button);
const canvas = element.ownerDocument
.createElement("canvas")
.getContext("2d");
if (canvas) canvas.font = style.font;
const inset =
parseFloat(style.paddingLeft) +
parseFloat(style.paddingRight) +
parseFloat(style.borderLeftWidth) +
parseFloat(style.borderRightWidth);
const gap = parseFloat(getComputedStyle(element).columnGap) || 0;
const required =
2 * button.getBoundingClientRect().width +
gap * (items.length + 1) +
items.reduce<number>(
(sum, item) =>
sum +
Math.max(
32,
(canvas?.measureText(
String(typeof item === "number" ? item : "…"),
).width ?? 16) + inset,
),
0,
);
const next = element.clientWidth < required;
const focused = element.ownerDocument.activeElement;
const willRemoveFocus = next
? focused?.hasAttribute("data-page-number")
: focused?.matches(".rbx-pagination-summary input");
if (
next !== element.hasAttribute("data-compact") &&
willRemoveFocus &&
element.contains(focused)
)
restoreFocus.current = true;
setCompact(next);
};
update();
const observer = new ResizeObserver(update);
observer.observe(element);
element.ownerDocument.fonts.addEventListener("loadingdone", update);
return () => {
observer.disconnect();
element.ownerDocument.fonts.removeEventListener("loadingdone", update);
};
}, [current, count]);
useLayoutEffect(() => {
if (!restoreFocus.current) return;
restoreFocus.current = false;
root.current?.querySelector<HTMLElement>('[aria-current="page"]')?.focus();
}, [compact]);
// 필터 변경으로 범위를 벗어나면 UI와 내부 상태·호출자의 데이터가 같은 페이지를 가리키게 합니다.
useEffect(() => {
if (!count || value === current) {
lastCorrection.current = null;
return;
}
const correction = String(value) + ":" + current;
if (lastCorrection.current === correction) return;
lastCorrection.current = correction;
if (page === undefined) setInternalPage(current);
onPageChange?.(current);
}, [count, value, current, page, onPageChange]);
function select(next: number) {
if (disabled || next === current || next < 1 || next > count) return;
if (page === undefined) setInternalPage(next);
onPageChange?.(next);
}
if (count === 0) return null;
return (
<nav
aria-label="페이지 이동"
ref={mergedRef}
data-compact={compact || undefined}
{...props}
className={cx("rbx-pagination", className)}
>
<Button
size="sm"
variant="utility"
aria-label={previousLabel}
disabled={disabled || current === 1}
onClick={() => select(current - 1)}
>
<Icon name="icon-regular-chevron-large-left" size={16} />
</Button>
{!compact &&
items.map((item) =>
typeof item === "number" ? (
<Button
key={item}
data-page-number={item}
size="sm"
variant={item === current ? "standard" : "utility"}
aria-label={getPageLabel(item)}
aria-current={item === current ? "page" : undefined}
disabled={disabled}
onClick={() => select(item)}
>
{item}
</Button>
) : (
<span key={item} className="rbx-pagination-gap" aria-hidden="true">
…
</span>
),
)}
{compact && (
<PageInput
page={current}
count={count}
disabled={disabled}
label={pageInputLabel}
onCommit={select}
/>
)}
<Button
size="sm"
variant="utility"
aria-label={nextLabel}
disabled={disabled || current === count}
onClick={() => select(current + 1)}
>
<Icon name="icon-regular-chevron-large-right" size={16} />
</Button>
</nav>
);
}
/** 좁은 화면에서도 여러 페이지를 한 번에 이동할 수 있게 직접 입력을 유지합니다. */
function PageInput({
page,
count,
disabled,
label,
onCommit,
}: {
page: number;
count: number;
disabled: boolean;
label: string;
onCommit: (page: number) => void;
}) {
const [draft, setDraft] = useState(String(page));
useEffect(() => {
setDraft(String(page));
}, [page, count]);
const commit = () => {
const number = Number(draft);
if (draft.trim() && Number.isFinite(number))
onCommit(Math.max(1, Math.min(count, Math.floor(number))));
setDraft(String(page));
};
return (
<span className="rbx-pagination-summary">
<input
aria-label={label}
aria-current="page"
inputMode="numeric"
value={draft}
disabled={disabled}
style={{ width: Math.min(String(count).length + 1, 8) + "ch" }}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commit();
} else if (event.key === "Escape") {
setDraft(String(page));
event.stopPropagation();
}
}}
/>
<span> / {count}</span>
</span>
);
}
components/ui/pagination.css
@layer theme, base, components, utilities;
@layer components.ply-base, components.ply;
@import "./button.css";
@layer components.ply {
.rbx-pagination {
display: flex;
flex-wrap: nowrap;
max-width: 100%;
min-width: 0;
align-items: center;
gap: 4px;
}
.rbx-pagination > .rbx-button,
.rbx-pagination-gap {
min-width: 32px;
padding-inline: 6px;
text-align: center;
}
.rbx-pagination .rbx-button[aria-current="page"] {
box-shadow: inset 0 0 0 1px var(--rbx-color-stroke-contrast-alpha);
}
.rbx-pagination-gap {
color: var(--rbx-color-content-default);
}
.rbx-pagination:dir(rtl) > .rbx-button .rbx-icon {
transform: rotate(180deg);
}
.rbx-pagination > .rbx-button {
flex-shrink: 0;
}
.rbx-pagination-summary {
min-width: 0;
flex: 1;
text-align: center;
overflow-wrap: anywhere;
font: var(--rbx-typography-body-small-font);
font-variant-numeric: tabular-nums;
}
.rbx-pagination-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 2px;
}
.rbx-pagination-summary input {
min-width: 2ch;
max-width: 100%;
min-height: 32px;
padding: 2px;
border: 1px solid var(--rbx-color-stroke-default);
border-radius: 4px;
background: transparent;
color: inherit;
font: inherit;
text-align: center;
}
.rbx-pagination-summary input:focus-visible {
outline: 2px solid var(--rbx-focus-ring);
outline-offset: 1px;
}
}