diff --git a/src/design-system/primitives/Pagination/Pagination.module.css b/src/design-system/primitives/Pagination/Pagination.module.css
new file mode 100644
index 0000000..4b19ba5
--- /dev/null
+++ b/src/design-system/primitives/Pagination/Pagination.module.css
@@ -0,0 +1,79 @@
+.pagination {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+}
+
+/* Prev / Next nav buttons — ghost style */
+.navBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ background: none;
+ border: none;
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.navBtn:hover:not(:disabled) {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.navBtn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/* Individual page number buttons */
+.pageBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ background: none;
+ border: none;
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.pageBtn:hover:not(:disabled) {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.pageBtn:disabled:not(.active) {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/* Active page */
+.active {
+ background: var(--amber);
+ color: white;
+ cursor: default;
+}
+
+/* Ellipsis */
+.ellipsis {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ user-select: none;
+}
diff --git a/src/design-system/primitives/Pagination/Pagination.test.tsx b/src/design-system/primitives/Pagination/Pagination.test.tsx
new file mode 100644
index 0000000..826c206
--- /dev/null
+++ b/src/design-system/primitives/Pagination/Pagination.test.tsx
@@ -0,0 +1,141 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Pagination, getPageRange } from './Pagination'
+
+describe('getPageRange', () => {
+ it('returns all pages when totalPages is small', () => {
+ expect(getPageRange(1, 5, 1)).toEqual([1, 2, 3, 4, 5])
+ })
+
+ it('returns only page 1 when totalPages is 1', () => {
+ expect(getPageRange(1, 1, 1)).toEqual([1])
+ })
+
+ it('adds left ellipsis when current page is far from start', () => {
+ const range = getPageRange(10, 20, 1)
+ expect(range[0]).toBe(1)
+ expect(range[1]).toBe('ellipsis')
+ })
+
+ it('adds right ellipsis when current page is far from end', () => {
+ const range = getPageRange(1, 20, 1)
+ const lastEllipsisIdx = range.lastIndexOf('ellipsis')
+ expect(lastEllipsisIdx).toBeGreaterThan(-1)
+ expect(range[range.length - 1]).toBe(20)
+ })
+
+ it('shows first and last page always', () => {
+ const range = getPageRange(5, 20, 1)
+ expect(range[0]).toBe(1)
+ expect(range[range.length - 1]).toBe(20)
+ })
+
+ it('renders pages around current with siblingCount=1', () => {
+ // page 5, totalPages 20, siblingCount 1 → 1 ... 4 5 6 ... 20
+ const range = getPageRange(5, 20, 1)
+ expect(range).toContain(4)
+ expect(range).toContain(5)
+ expect(range).toContain(6)
+ })
+
+ it('renders wider sibling window with siblingCount=2', () => {
+ // page 10, totalPages 20, siblingCount 2 → 1 ... 8 9 10 11 12 ... 20
+ const range = getPageRange(10, 20, 2)
+ expect(range).toContain(8)
+ expect(range).toContain(9)
+ expect(range).toContain(10)
+ expect(range).toContain(11)
+ expect(range).toContain(12)
+ })
+
+ it('does not duplicate page 1 when siblings reach the start', () => {
+ const range = getPageRange(2, 20, 1)
+ const ones = range.filter(x => x === 1)
+ expect(ones).toHaveLength(1)
+ })
+
+ it('does not duplicate last page when siblings reach the end', () => {
+ const range = getPageRange(19, 20, 1)
+ const twenties = range.filter(x => x === 20)
+ expect(twenties).toHaveLength(1)
+ })
+})
+
+describe('Pagination', () => {
+ it('renders prev and next buttons', () => {
+ render()
+ expect(screen.getByRole('button', { name: 'Previous page' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeInTheDocument()
+ })
+
+ it('marks the current page with aria-current', () => {
+ render()
+ const activeBtn = screen.getByRole('button', { name: 'Page 5' })
+ expect(activeBtn).toHaveAttribute('aria-current', 'page')
+ })
+
+ it('shows ellipsis when pages are far apart', () => {
+ render()
+ const ellipses = screen.getAllByText('…')
+ expect(ellipses.length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('disables prev button on first page', () => {
+ render()
+ expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled()
+ })
+
+ it('does not disable next button on first page', () => {
+ render()
+ expect(screen.getByRole('button', { name: 'Next page' })).not.toBeDisabled()
+ })
+
+ it('disables next button on last page', () => {
+ render()
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()
+ })
+
+ it('does not disable prev button on last page', () => {
+ render()
+ expect(screen.getByRole('button', { name: 'Previous page' })).not.toBeDisabled()
+ })
+
+ it('fires onPageChange with page - 1 when prev is clicked', async () => {
+ const user = userEvent.setup()
+ const onPageChange = vi.fn()
+ render()
+ await user.click(screen.getByRole('button', { name: 'Previous page' }))
+ expect(onPageChange).toHaveBeenCalledWith(4)
+ })
+
+ it('fires onPageChange with page + 1 when next is clicked', async () => {
+ const user = userEvent.setup()
+ const onPageChange = vi.fn()
+ render()
+ await user.click(screen.getByRole('button', { name: 'Next page' }))
+ expect(onPageChange).toHaveBeenCalledWith(6)
+ })
+
+ it('fires onPageChange with the clicked page number', async () => {
+ const user = userEvent.setup()
+ const onPageChange = vi.fn()
+ render()
+ await user.click(screen.getByRole('button', { name: 'Page 6' }))
+ expect(onPageChange).toHaveBeenCalledWith(6)
+ })
+
+ it('applies custom className', () => {
+ const { container } = render(
+
+ )
+ expect(container.firstChild).toHaveClass('custom-class')
+ })
+
+ it('renders with siblingCount=0 showing only current page between first and last', () => {
+ render()
+ expect(screen.getByRole('button', { name: 'Page 10' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Page 1' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Page 20' })).toBeInTheDocument()
+ })
+})
diff --git a/src/design-system/primitives/Pagination/Pagination.tsx b/src/design-system/primitives/Pagination/Pagination.tsx
new file mode 100644
index 0000000..9bcf75a
--- /dev/null
+++ b/src/design-system/primitives/Pagination/Pagination.tsx
@@ -0,0 +1,113 @@
+import styles from './Pagination.module.css'
+import type { HTMLAttributes } from 'react'
+
+interface PaginationProps extends HTMLAttributes {
+ page: number
+ totalPages: number
+ onPageChange: (page: number) => void
+ siblingCount?: number
+ className?: string
+}
+
+export function getPageRange(
+ page: number,
+ totalPages: number,
+ siblingCount: number
+): (number | 'ellipsis')[] {
+ if (totalPages <= 1) return [1]
+
+ // If total pages fit in a compact window, just return all pages with no ellipsis
+ const totalPageNumbers = siblingCount * 2 + 5 // 1(first) + 1(last) + 1(current) + 2*siblings + 2(ellipsis slots)
+ if (totalPages <= totalPageNumbers) {
+ return Array.from({ length: totalPages }, (_, i) => i + 1)
+ }
+
+ const range: (number | 'ellipsis')[] = []
+
+ const left = Math.max(2, page - siblingCount)
+ const right = Math.min(totalPages - 1, page + siblingCount)
+
+ // First page always shown
+ range.push(1)
+
+ // Left ellipsis: gap between 1 and left boundary hides at least 1 page
+ if (left > 2) {
+ range.push('ellipsis')
+ }
+
+ // Middle pages
+ for (let i = left; i <= right; i++) {
+ range.push(i)
+ }
+
+ // Right ellipsis: gap between right boundary and last page hides at least 1 page
+ if (right < totalPages - 1) {
+ range.push('ellipsis')
+ }
+
+ // Last page always shown
+ range.push(totalPages)
+
+ return range
+}
+
+export function Pagination({
+ page,
+ totalPages,
+ onPageChange,
+ siblingCount = 1,
+ className,
+ ...rest
+}: PaginationProps) {
+ const pageRange = getPageRange(page, totalPages, siblingCount)
+
+ return (
+
+ )
+}