Featuring Design System
Columns

Pagination

TanStack 표준 페이지네이션 — 클라이언트 슬라이스, 서버 manual 모드, 절대 aria-rowindex 좌표.

개요

페이지네이션은 TanStack Table이 소유합니다. 그리드는 (1) 현재 페이지 행만 렌더하고, (2) 각 행의 aria-rowindex에 페이지 시작 절대좌표를 가산해 AT가 페이지를 넘어가도 행 번호를 정확히 읽을 수 있게 합니다.

페이지 이동 버튼이나 페이지당 개수 선택 같은 UI는 그리드가 소유하지 않습니다. 소비자가 DS 프리미티브(Pagination / Select)로 직접 합성합니다.

두 가지 모드가 있습니다.

모드설정그리드 동작
클라이언트getPaginationRowModel() 옵션TanStack이 pageIndex × pageSize로 slice
서버(manual)manualPagination: true + rowCount소비자가 현재 페이지 행만 fetch해 주입 — 그리드는 slice하지 않음

어느 모드든 aria-rowindex 절대좌표 공식은 동일합니다.

기본 사용법

클라이언트 페이지네이션은 getPaginationRowModel()useDataGrid 옵션에 넘기고, state.pagination + onPaginationChange로 상태를 컨트롤하는 것이 전부입니다.

const data = [
{ id: '1', name: '김민준', followers: 128000 },
{ id: '2', name: '이서연', followers: 84500 },
{ id: '3', name: '박도윤', followers: 233000 },
{ id: '4', name: '최하은', followers: 45200 },
{ id: '5', name: '정시우', followers: 67800 },
{ id: '6', name: '강지우', followers: 152000 },
{ id: '7', name: '윤서준', followers: 39100 },
{ id: '8', name: '임하준', followers: 98700 },
{ id: '9', name: '한지호', followers: 211000 },
{ id: '10', name: '오은우', followers: 56300 },
{ id: '11', name: '신예은', followers: 73400 },
{ id: '12', name: '권도현', followers: 187600 },
];

const columns = [
{
  accessorKey: 'name',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'followers',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>팔로워</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue().toLocaleString()}</DataGrid.Cell>,
},
];

function PaginatedGrid() {
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 5 });

const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  getRowId: (row) => row.id,
  state: { pagination },
  onPaginationChange: setPagination,
});

const pageCount = table.getPageCount();

return (
  <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
    <DataGrid.Root table={table}>
      <DataGrid.Header />
      <DataGrid.Body />
    </DataGrid.Root>

    <Center>
      <Pagination.Root
        total={pageCount}
        page={pagination.pageIndex + 1}
        onPageChange={(page) => setPagination((p) => ({ ...p, pageIndex: page - 1 }))}
      >
        <Pagination.PrevTrigger />
        <Pagination.Items />
        <Pagination.NextTrigger />
      </Pagination.Root>
    </Center>
  </div>
);
}

render(<PaginatedGrid />);

state.pagination의 타입은 { pageIndex: number; pageSize: number }입니다. pageIndex는 0-based이고, DS Pagination.Rootpage/onPageChange는 1-based라 변환(pageIndex + 1page - 1)이 필요합니다.

table.getPageCount()Math.ceil(전체 행 수 / pageSize)를 반환합니다. 이 값을 Pagination.Roottotal에 그대로 넘기면 됩니다.

페이지 크기

pageSize를 바꿀 때는 반드시 pageIndex를 0으로 리셋합니다. 현재 페이지가 새 pageSize 기준 범위를 벗어나는 상황을 막기 위함이며, 이 정책은 그리드가 아니라 소비자 몫입니다.

import { Select } from '@featuring-corp/components';

// PAGE_SIZE_OPTIONS 예: [{ value: '5', label: '5개' }, { value: '10', label: '10개' }, ...]

<Select.Root
  size="sm"
  value={String(pagination.pageSize)}
  onValueChange={(v) => setPagination({ pageIndex: 0, pageSize: Number(v) })}
  items={PAGE_SIZE_OPTIONS}
>
  <Select.Trigger>
    <Select.Value />
  </Select.Trigger>
  <Select.Portal>
    <Select.Positioner>
      <Select.Popup>
        {PAGE_SIZE_OPTIONS.map((opt) => (
          <Select.Item key={opt.value} value={opt.value}>
            <Select.ItemText>{opt.label}</Select.ItemText>
          </Select.Item>
        ))}
      </Select.Popup>
    </Select.Positioner>
  </Select.Portal>
</Select.Root>

페이지당 개수 선택과 페이지 컨트롤을 함께 쓰는 전체 레시피는 Storybook PageSizeControl 스토리에서 확인하세요.

서버 페이지네이션 (manual)

서버에서 현재 페이지 행만 내려주는 경우에는 manualPagination: truerowCount를 함께 지정합니다. 그리드는 slice하지 않고, 소비자가 넘긴 data를 그대로 렌더합니다.

import { useMemo, useState } from 'react';

function ServerPaginatedGrid({ columns }) {
  const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 5 });

  // 소비자가 현재 페이지 행만 fetch (예: SWR / React Query)
  const { data, isLoading, error } = useFetchPage({
    pageIndex: pagination.pageIndex,
    pageSize: pagination.pageSize,
  });
  const items = useMemo(() => data?.items ?? [], [data]);

  const table = useDataGrid({
    data: items,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getRowId: (row) => row.id,
    manualPagination: true,
    rowCount: data?.total,           // 전체 행 수 — aria-rowcount 및 getPageCount()의 진실
    manualSorting: true,
    manualFiltering: true,
    state: { pagination },
    onPaginationChange: setPagination,
    loading: isLoading,
    error,
    skeletonRows: pagination.pageSize, // fetch 동안 자리 유지 (layout shifting 방지)
  });

  // server 모드에서 페이지 수는 rowCount 기준으로 직접 계산
  const pageCount = Math.max(1, Math.ceil((data?.total ?? 0) / pagination.pageSize));

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
      <DataGrid.Root table={table}>
        <DataGrid.Header />
        <DataGrid.Body />
      </DataGrid.Root>

      <Center>
        <Pagination.Root
          total={pageCount}
          page={pagination.pageIndex + 1}
          onPageChange={(page) => setPagination((p) => ({ ...p, pageIndex: page - 1 }))}
        >
          <Pagination.PrevTrigger />
          <Pagination.Items />
          <Pagination.NextTrigger />
        </Pagination.Root>
      </Center>
    </div>
  );
}

서버 모드에서 rowCount를 지정하지 않으면 전체 행 수를 알 수 없으므로, 그리드는 aria-rowcount를 아예 생략합니다(잘못된 값을 내보내지 않고 degrade). AT가 "전체 N행 중 X행"을 안내하지 못하니 서버 모드에서는 rowCount를 넘기세요.

skeletonRowspagination.pageSize로 맞추면 페이지 이동 중 fetch가 일어나는 동안 그리드 높이가 유지됩니다. 스켈레톤 행은 aria-hidden이라 AT에 노출되지 않습니다.

Prop

Type

aria-rowindex — 절대 좌표

페이지네이션이 걸리면 DOM에는 현재 페이지 행만 존재합니다. AT가 "전체 N행 중 X번째 행"을 정확히 안내하려면, 각 행이 전체 기준 절대 위치를 선언해야 합니다.

그리드는 Row에서 다음 공식으로 aria-rowindex를 계산합니다.

// Row.tsx (요지)
const isPaginated =
  table.options.manualPagination === true || table.options.getPaginationRowModel != null;
const pageOffset = isPaginated && pagination ? pagination.pageIndex * pagination.pageSize : 0;
const ariaRowIndex = rowIndex !== undefined ? headerRowCount + pageOffset + rowIndex + 1 : undefined;
  • headerRowCount — 헤더 그룹 수(table.getHeaderGroups().length). 헤더 행도 aria-rowindex 공간을 점유합니다(1..H).
  • pageOffsetpageIndex × pageSize. 2페이지 이후의 행이 1부터 재시작하지 않도록 절대 좌표를 가산합니다.
  • rowIndexBody가 넘기는 페이지 내부 0-based 위치(가상화=virtualItem.index, 일반=map 인덱스).

결과적으로 pageSize=10 기준 2페이지의 첫 번째 데이터 행은 aria-rowindex = 1(헤더) + 10(offset) + 0 + 1 = 12를 가집니다. aria-rowcount는 전체 행 수 + 헤더 수로 페이지와 무관하게 고정되어, aria-rowindex ≤ aria-rowcount 단조 불변이 유지됩니다.

이 동작은 클라이언트 모드와 서버 모드 모두 동일합니다. isPaginated 판별이 두 경로를 같은 공식으로 처리합니다.

render-prop으로 Row를 직접 그릴 때: 비가상화면 rowIndex를 생략해도 브라우저가 DOM 순서로 산출하므로 괜찮습니다. 가상화 + render-prop이면 화면 밖 행이 DOM에 없으니 소비자가 rowIndex를 명시해야 위치 안내가 유지됩니다. 자세한 내용은 Accessibility를 참고하세요.

Pagination 컴포넌트 연동

DS Paginationtotal(페이지 수)과 page(현재 1-based 페이지)를 받아 이전/번호/다음 트리거를 합성합니다. PrevTrigger / NextTrigger는 경계(1페이지 / 마지막 페이지)에서 자동으로 비활성됩니다.

<Pagination.Root
  total={pageCount}
  page={pagination.pageIndex + 1}
  onPageChange={(page) => setPagination((p) => ({ ...p, pageIndex: page - 1 }))}
>
  <Pagination.PrevTrigger />
  <Pagination.Items />
  <Pagination.NextTrigger />
</Pagination.Root>
prop타입설명
totalnumber전체 페이지 수. 클라이언트: table.getPageCount(), 서버: Math.ceil(rowCount / pageSize)
pagenumber현재 페이지(1-based). pagination.pageIndex + 1
onPageChange(page: number) => void페이지 이동 시 호출. pageIndex로 변환해 setPagination에 전달

페이지당 개수 선택(Select)과 총계 표시를 포함한 전체 툴바 레시피는 Storybook에서 확인하세요.

다음 단계

  • Sorting — 정렬과 페이지네이션의 조합 (manualSorting + 서버 fetch).
  • Filtering — 필터와 페이지네이션의 조합 (manualFiltering + pageIndex 리셋).
  • Server-side — 정렬·필터·페이지네이션을 서버에 위임하는 전체 레시피.
  • Accessibilityaria-rowindex 절대 좌표의 전체 설계와 render-prop 가상화 주의사항.
  • Storybook — Pagination 스토리 — 클라이언트·서버·페이지 크기 선택 살아있는 예제.