Pagination
여러 쪽으로 나뉜 목록의 쪽을 오가는 줄 — 쪽 단위로 훑는 목록(주로 데스크톱 768 이상) 아래 가운데에 둔다. 몇 쪽인지 한눈에 보이고, 쪽이 바뀌어도 칸 자리가 흔들리지 않는다. 폰(768 미만)의 긴 목록은 쪽을 나누지 않고 끝없이 불러온다(아래 "폰은 끝없이 불러오기"). 데이터 표는 Table Pagination 이다.
구조는 당근 SEED Pagination(Apache-2.0)을 따른다 — "여러 페이지로 나뉜 콘텐츠를 탐색할 수 있도록 돕는 네비게이션 컴포넌트" 로, 이전 · 다음 버튼 · 쪽 번호 · 생략. 번호 · 화살표 칸 40 × 40 을 사이 없이 잇고, 지금 쪽은 짙게 채우며, 칸 수는 화면 폭으로 고정이다(480 이상 9칸 · 미만 7칸). 값은 groo 토큰이고, SEED 와 다른 자리는 맨 아래 "SEED 와 다른 점" 에 적었다(2026-10-04 사용자 결정). 옛 Pagination 스펙(shadcn — 지금 쪽 테두리 · 칸 사이 4 · 이전 · 다음 글자)을 대신한다.
수치 원본은 pagination.yaml(넘김 줄)과 infinite-list.yaml(폰의 끝없이 불러오기 — 목록 끝 자리)이다. 수치 표 자리([표: …])와 그림 자리([그림: …])는 사이트가 그 파일로 그린다 — GitHub 에서는 그 파일 · 그림 코드로 가는 링크로 보인다.
직접 골라 보기
전체 쪽 수 · 지금 쪽 · 화면 폭(480 이상 · 미만)을 고르면 스펙대로 그린 줄과 그 코드가 바뀐다. 실제로 누르면 칸 자리는 그대로이고 생략(…)만 옮겨 간다. 첫 · 마지막 쪽에서는 화살표 자리가 비고, 키보드로 끝 쪽에 닿으면 초점이 지금 쪽 번호로 옮겨 간다.
import { useSearchParams } from "react-router-dom"
import { Pagination } from "@/components/ui/pagination"
const [params] = useSearchParams()
const page = Number(params.get("page") ?? 1)
<section ref={listRef} aria-labelledby="card-list-title">…</section>
{/* 칸은 링크 — 쪽마다 방문 기록이 쌓여 뒤로 가기가 앞 쪽으로 온다 */}
<Pagination totalPages={12} page={page} getHref={(p) => `?page=${p}`} scrollTarget={listRef} aria-label="카드 혜택 페이지 탐색" />Anatomy
| 부위 | 설명 |
|---|---|
| ⓐ Root | 넘김 줄 — 목록 아래 가운데. 칸을 사이 없이 잇는다. |
| ⓑ Previous · Next | 이전 · 다음 — 아이콘만 40 × 40. 첫 · 마지막 쪽에서는 그 자리가 빈 칸이다. |
| ⓒ Page | 쪽 번호 — 40 × 40, 14 / 19 · 700. 지금 쪽은 짙은 채움. |
| ⓓ Ellipsis | 생략 — 40 상자 + 점 셋. 누르지 않는다. |
부위
| 부위 | 설명 |
|---|---|
root | 넘김 줄 — 목록 아래 가운데. 칸을 사이 없이 잇는다 |
item | 쪽 번호 칸 — 40 × 40 |
label | 쪽 번호 — 숫자 폭을 같게 |
arrow | 이전 · 다음 — 아이콘만 40 × 40 |
ellipsis | 생략 — 40 상자 + 점 셋. 누르지 않는다 |
empty | 빈 칸 — 첫 · 마지막 쪽에서 화살표 자리 40 |
focusRing | 키보드 포커스 링(웹) |
Properties
칸
번호 · 화살표 · 생략 칸은 모두 40 × 40 이고 사이 없이 붙는다. 번호는 14 / 19 · 700 · fg-neutral · 숫자 폭을 같게, 화살표는 아이콘만(chevron 16), 모서리는 8 이다. 바탕은 누를 때 · 마우스를 올릴 때만 보인다.
쪽 번호
| 속성 | 값 | 비고 |
|---|---|---|
| 크기 | 40px | 40 × 40 — 칸 40 그대로(사용자 결정 2026-10-04) |
| 모서리 | radius-r2 8px | |
| 배경 | transparent | 누를 때 · 마우스를 올릴 때만 칠한다(지금 쪽은 채움) |
| 터치 영역 | 40 × 44 | 위아래만 44(보이지 않는 여백 2 씩), 옆은 칸 폭 40 — v106 '누르는 영역 44' 의 예외. AA ✓ · AAA ⚠ |
| 커서 | pointer | |
| 전환 대상 | background-color, scale | 바탕 색과 누름 축소 둘 다 — 다른 누르는 칸(card · chart …)과 같은 표기, SEED pagination-button 도 colorDuration · scaleDuration 둘(Q-011-6). 시간은 motion 절 |
| 전환 시간 | motion-duration-color-transition 150ms | |
| 전환 곡선 | motion-ease-easing cubic-bezier(0.35, 0, 0.35, 1) |
번호 글자
| 속성 | 값 | 비고 |
|---|---|---|
| 글자 | text-t4 14px / 19px | |
| 굵기 | 700 | |
| 글자·아이콘 | color-fg-neutral #1A1F2E · 다크 #F5F6FA | |
| 숫자 폭 | tabular-nums | 숫자 폭을 같게. 네 자리 이상이 칸을 넘으면 말줄임 — 숫자는 칸 이름에 있다 |
이전 · 다음
| 속성 | 값 | 비고 |
|---|---|---|
| 크기 | 40px | 이전 · 다음 — 아이콘만. 누르는 영역은 쪽 번호와 같다(40 × 44) |
| 모서리 | radius-r2 8px | |
| 아이콘 크기 | 16px | lucide chevron-left · chevron-right |
| 색 | color-fg-neutral #1A1F2E · 다크 #F5F6FA | |
| 배경 | transparent | |
| 전환 대상 | background-color, scale | 쪽 번호 칸과 같다(Q-011-6) |
| 전환 시간 | motion-duration-color-transition 150ms | |
| 전환 곡선 | motion-ease-easing cubic-bezier(0.35, 0, 0.35, 1) |
넘김 줄
| 속성 | 값 | 비고 |
|---|---|---|
| 간격 | 0px | 칸이 붙는다(SEED) — 9칸이면 360, 7칸이면 280 |
| 가로 맞춤 | center | 목록 아래 가운데(SEED) |
| 위 바깥 여백 | spacing-x6 24px | 목록 끝 → 넘김 줄 24(옛 DESIGN.md Pagination 절의 값 — SEED 는 "목록 하단" 만 적었다) |
지금 쪽
지금 쪽은 짙은 채움(bg-neutral-inverted) + 반전 글자(fg-neutral-inverted)다 — 다크에서는 밝은 칸에 짙은 글자다. 브랜드 색으로 칠하지 않는다. 보조 기술에는 aria-current="page" 로 알린다.
지금 쪽
| current | item 배경 | label 글자·아이콘 | 설명 |
|---|---|---|---|
other (기본) | — | — | 다른 쪽 |
current | color-bg-neutral-inverted #1A1F2E · 다크 #F5F6FA — 짙은 채움 — SEED bg.neutral-solid 의 groo 짝(다크는 밝은 반전) | color-fg-neutral-inverted #FFFFFF · 다크 #242938 | 지금 쪽 — 짙은 채움 |
칸 수 — 9 · 7
칸 수는 화면 폭으로 고정이다 — 480 이상 9칸, 480 미만 7칸(화살표 둘 포함). 쪽이 바뀌어도 줄의 폭과 칸 자리는 그대로이고 생략(…)만 옮겨 간다. 전체 쪽이 칸 수보다 적으면 모든 번호를 보인다.
| 지금 쪽 | 9칸(480 이상) | 7칸(480 미만) |
|---|---|---|
| 앞쪽 | ‹ 1 2 3 4 5 … N › | ‹ 1 2 3 4 … › |
| 가운데 | ‹ 1 … p−1 p p+1 … N › | ‹ … p−1 p p+1 … › |
| 뒤쪽 | ‹ 1 … N−4 N−3 N−2 N−1 N › | ‹ … N−3 N−2 N−1 N › |
7칸의 가운데 구간에서는 첫 · 마지막 번호가 없다(SEED) — 앞뒤 쪽을 바로 고르는 것을 먼저 둔다.
칸 수
| count | 너비 | 설명 |
|---|---|---|
regular (기본) | 360px — 9칸 — 화살표 2 + 번호 · 생략 7. 1 2 3 4 5 … N · 1 … p−1 p p+1 … N · 1 … N−4 N−3 N−2 N−1 N | 9칸 — 480 이상(화살표 둘 포함) |
narrow | 280px — 7칸 — 화살표 2 + 번호 · 생략 5. 1 2 3 4 … · … p−1 p p+1 … · … N−3 N−2 N−1 N(가운데 구간에서는 첫 · 마지막 번호가 없다) | 7칸 — 480 미만(화살표 둘 포함) |
끝 쪽 — 빈 칸
첫 쪽에서는 이전 자리에, 마지막 쪽에서는 다음 자리에 40 빈 칸을 둔다 — 막힌 화살표를 그리지 않고 자리만 지킨다(SEED). 키보드로 "다음" 을 눌러 마지막 쪽에 닿으면 누르던 버튼이 빈 칸이 되므로, 초점을 지금 쪽 번호로 옮긴다(초점이 화면 맨 앞으로 떨어지지 않게).
누르는 영역 — 40 의 예외
칸은 40 이다 — 누르는 영역은 위아래만 44 로 넓히고(보이지 않는 여백 2 씩), 옆은 칸 폭 40 그대로다. 칸이 붙어 있어 옆으로 넓히면 이웃 칸과 겹친다. 이것은 v106 "누르는 영역은 늘 44 이상" 의 예외다 — groo 폰은 끝없이 불러오기라 넘김 줄은 주로 마우스로 누른다(사용자 결정 2026-10-04). WCAG 2.5.8(AA, 24) 은 통과하고 2.5.5(AAA, 44) 는 옆으로 미달이다.
State
| 상태 | 모습 |
|---|---|
enabled | 바탕 없음(지금 쪽은 짙은 채움) |
hovered | 웹 — bg-layer-default-pressed(지금 쪽은 bg-neutral-inverted-pressed), 축소 없음 |
pressed | 같은 바탕 + 2px 거리 축소 |
focused | 웹 — 키보드 포커스에만 칸 바깥 2px 링 |
disabled | 목록을 다시 받는 동안 — 번호 · 화살표 fg-disabled, 지금 쪽은 bg-disabled |
상태 — 다른 쪽
| 상태 | item 배경 | item 배율 | item 커서 | item 축소 시간 | item 축소 곡선 |
|---|---|---|---|---|---|
enabled 기본 | transparent — 누를 때 · 마우스를 올릴 때만 칠한다(지금 쪽은 채움) | — | pointer | — | — |
hovered 웹 — 누름과 같은 바탕, 축소 없음 | color-bg-layer-default-pressed #F7F8FD · 다크 #353B4D — 누름과 같은 색 — 축소 없음(v106) | — | pointer | — | — |
pressed 누름 — 바탕 + 2px 거리 축소 | color-bg-layer-default-pressed #F7F8FD · 다크 #353B4D — SEED bg.transparent-pressed 의 불투명 짝 | 2px 거리 축소 — 기준 40 → 0.95(v104 — SEED 0.97). 모션 줄이기면 하지 않는다 | pointer | motion-duration-pressed-scale 150ms | motion-ease-pressed-scale cubic-bezier(0, 0, 0.15, 1) |
disabled 막힌 넘김(목록을 다시 받는 동안 · 막힌 화면) — 전용 색 | transparent | — | not-allowed | — | — |
상태 — 지금 쪽
| 상태 | item 배경 | item 배율 | item 커서 | item 축소 시간 | item 축소 곡선 |
|---|---|---|---|---|---|
enabled 기본 | color-bg-neutral-inverted #1A1F2E · 다크 #F5F6FA — 짙은 채움 — SEED bg.neutral-solid 의 groo 짝(다크는 밝은 반전) | — | pointer | — | — |
hovered 웹 — 누름과 같은 바탕, 축소 없음 | color-bg-neutral-inverted-pressed #535866 · 다크 #B7BDCC | — | pointer | — | — |
pressed 누름 — 바탕 + 2px 거리 축소 | color-bg-neutral-inverted-pressed #535866 · 다크 #B7BDCC — v112 — Button neutralSolid 의 누름과 같다 | 2px 거리 축소 — 기준 40 → 0.95(v104 — SEED 0.97). 모션 줄이기면 하지 않는다 | pointer | motion-duration-pressed-scale 150ms | motion-ease-pressed-scale cubic-bezier(0, 0, 0.15, 1) |
disabled 막힌 넘김(목록을 다시 받는 동안 · 막힌 화면) — 전용 색 | color-bg-disabled #F5F6FA · 다크 #353B4D — 흐리게 하지 않는다(v106) | — | not-allowed | — | — |
모션
| 전환 | 시간 | 곡선 | 대상 | 비고 |
|---|---|---|---|---|
바탕 색 | motion-duration-color-transition 150ms | motion-ease-easing cubic-bezier(0.35, 0, 0.35, 1) | background-color | |
누름 | motion-duration-pressed-scale 150ms | motion-ease-pressed-scale cubic-bezier(0, 0, 0.15, 1) | scale | 2px 거리 축소 — 모션 줄이기면 하지 않는다 |
Guidelines
언제 쪽을 나누나
동작 상황으로 고른다 — "늘 데스크톱은 Pagination" 이 아니다(2026-10-23 사용자 결정 — 가계부 다시 짜기 https://claude.ai/artifact/CCAra9mCf1q77pqgM1D2xc 5A). 사용자 말: "데스크톱처럼 쪽 단위로 훑는 자리는 Pagination 이 좋을 수 있지만, 스크롤로 내려가는 환경(폰 · 긴 목록)은 끝에 닿으면 저절로 더 불러오는 쪽이 동작상 자연스럽다. 가계부처럼 달이 자연스러운 묶음인 목록은 달 이동이 쪽 넘김이다."
| 이런 목록 | 쓰는 것 |
|---|---|
| 쪽 단위로 훑는 자리 — 데스크톱(검색 결과 · 카드 혜택 격자) | Pagination 이 좋을 수 있다 — 몇 쪽인지 · 어디쯤인지 보이고 주소로 돌아온다 |
| 스크롤로 내려가는 환경 — 폰 · 긴 목록 | 끝없이 불러오기 — 아래 |
| 자연스러운 묶음이 있는 목록(가계부 거래 — 달) | 묶음 옮기기가 쪽 넘김 — 달 하나가 한 쪽이고, 넘김 줄을 따로 두지 않는다 |
| 데이터 표(직원 · 휴가 내역 · 회비) | Table Pagination — 줄 수 · 범위 |
| 전체가 화면 3 ~ 4개 분량 이하 | 쪽을 나누지 않고 한 번에(SEED) |
| 단계를 거치는 흐름(가져오기 · 처음 쓰기) | 쪽 넘김이 아니라 단계 버튼 |
1쪽 이하면 넘김 줄을 그리지 않는다. 같은 목록이 폰에서는 끝없이 불러오고 데스크톱에서는 쪽을 나눌 수 있다(지금 카드 혜택과 같다). 이 표는 고르는 참고다 — 화면마다 동작으로 고른다. 화면 틀 쪽 표는 DESIGN.md Layout "화면 틀" 의 "목록 넘기기".
쪽을 넘기면
쪽을 넘기면 목록의 위 끝이 화면 위에 오게 스크롤하고, 보조 기술에 "2페이지, 전체 12페이지" 를 한 번 알린다(숨은 상태 글). 초점은 누른 칸에 남는다 — 끝 쪽에 닿아 그 칸이 빈 칸이 되면 지금 쪽 번호로 옮긴다. 넘기는 동안 목록은 Skeleton 의 "다른 내용을 받을 때" 를 따른다(바뀔 줄만 기다리고, 옛 쪽의 줄을 남기지 않는다).
쪽은 주소에 — 칸은 링크
웹 화면의 목록은 쪽을 주소에 둔다(?page=3) — 칸이 링크(<a>)라 새 탭에서 열고 주소를 나눌 수 있고, 뒤로 가기가 앞 쪽으로 돌아온다(쪽마다 방문 기록이 쌓인다). 주소가 없는 자리(대화상자 · 시트 안 목록)만 칸이 버튼이다. 어느 쪽이든 모양 · 키보드 · 이름은 같다.
폰은 끝없이 불러오기
768 미만의 긴 목록은 쪽을 나누지 않는다 — 목록 끝이 화면 아래에서 300 안으로 오면 다음 쪽을 받아 아래에 잇는다. 목록 맨 아래 한 자리가 지금 상태를 보인다(infinite-list.yaml).
| 상태 | 목록 끝 자리 |
|---|---|
| 받는 중 | Progress Circle 24 가운데 — 1초가 지나야 보인다(시간표는 Skeleton 의 "기다리는 동안") |
| 못 불러옴 | "더 불러오지 못했어요." + Button neutralWeak small "다시 시도" — 이미 받은 줄은 그대로 |
| 끝 | "모두 봤어요."(목록마다 "카드를 모두 봤어요." 처럼) — 한 쪽으로 끝나는 짧은 목록에는 두지 않는다 |
폰 목록은 둘로 가른다(2026-10-22 사용자 결정 6B — https://claude.ai/artifact/NuEPiLRH2fJidXdKzcDsCX) — 목록 화면의 긴 목록은 끝없이 불러온다(위). 카드 · 띠 안의 몇 줄 미리보기는 머리의 "전체 보기" 로 목록 화면에 보낸다(Card 의 머리 동작). 목록 끝에 줄을 덧붙이는 "더 보기" 버튼은 두지 않는다 — 끝없이 불러오기와 두 벌이 된다. 데스크톱은 쪽 넘김이다. 첫 쪽을 못 불러오면 목록 자리가 Result Section 의 실패다. 끝없이 불러오는 목록이 길어져도 맨 위로 가는 길은 상단 바 · 탭 바(지금 탭 다시 누르기)가 맡는다.
목록 끝 자리
| status | progress 크기 | message 글자·아이콘 | message 글자 | message 굵기 | retryButton 버튼 크기 | retryButton 위 바깥 여백 | 설명 |
|---|---|---|---|---|---|---|---|
loading (기본) | 24px — Progress Circle 24 · neutral — skeleton.md 의 '목록 끝 더 불러오기'. 1초가 지나야 보인다(그 전에 오면 깜빡이지 않는다) | — | — | — | — | — | 다음 쪽을 받는 중 — 원 |
error | — | color-fg-neutral-muted #535866 · 다크 #B7BDCC — "더 불러오지 못했어요." — 이미 받은 줄은 그대로 둔다. 문장으로 된 상태 문구라 muted(2026-10-16 사용자 결정 글자 역할 D5 — 그대로 둔다) | text-t4 14px / 400 / 19px | 400 | small 36 — Button neutralWeak small "다시 시도" — 누르면 그 쪽을 다시 받는다(로딩을 건다) | spacing-x3 12px | 못 불러옴 — "더 불러오지 못했어요." + 다시 시도 |
end | — | color-fg-neutral-subtle #62697A · 다크 #A2A8B7 — "모두 봤어요."(목록마다 "카드를 모두 봤어요." 처럼 바꿀 수 있다) — 바탕 위 5.50 · 다크 6.09. 한 쪽만으로 끝나는 짧은 목록에는 두지 않는다 | text-t4 14px / 400 / 19px | 400 | — | — | 끝 — "모두 봤어요." |
목록 끝 자리 — 공통
| 부위 | 속성 | 값 | 비고 |
|---|---|---|---|
root | 상하 여백 | spacing-x6 24px | 목록 마지막 줄 아래 24 · 그 아래는 화면 바닥 여백(탭 바가 있으면 그 여백) |
root | 가로 맞춤 | center | |
sentinel | 문턱 | 300px | 목록 끝이 화면 아래 끝에서 300 안으로 오면 다음 쪽을 받는다(지금 Desk 앱 값). 받는 중이거나 끝이면 부르지 않는다 |
글 · 이름
넘김 줄의 이름은 "페이지 탐색"(목록이 둘 이상이면 "카드 혜택 페이지 탐색" 처럼), 칸 이름은 "N페이지", 화살표는 "이전 페이지" · "다음 페이지" 다(SEED 문구). 영어("pagination" · "More pages" · "Page x of y")를 쓰지 않는다.
코드
레시피 recipes/shadcn/components/ui/pagination.tsx 를 쓴다. 아래 미리보기는 스펙 값으로 그린 모습이다.
Pagination— 넘김 줄(<nav aria-label="페이지 탐색">).totalPages(1 이하면 그리지 않는다) ·page·defaultPage(1) ·onPageChange(page, { reason })(reason—"page-item"·"previous"·"next") ·getHref(page)(주면 칸이 링크 — 쪽이 주소에 있을 때) ·scrollTarget(목록 상자 — 쪽이 바뀌면 그 위 끝으로 스크롤) ·disabled·aria-label. 칸 수는breakpoint-sm(480)으로 9 · 7 을 스스로 고른다. 쪽이 바뀌면 숨은 상태 글로 "N페이지, 전체 M페이지" 를 알리고, 끝 쪽에서 초점을 지금 쪽 번호로 옮긴다.paginationItems(page, totalPages, slots)— 칸 배열({ type: "page" | "ellipsis" | "previous" | "next" | "empty", page? })을 돌려주는 규칙 함수. 앱도 같은 규칙으로 쓴다.InfiniteListEnd— 폰 목록 끝 자리.status("loading"·"error"·"end") ·onRetry·endText(기본 "모두 봤어요.") ·onReachEnd(목록 끝이 300 안으로 오면 한 번 부른다 —status가"loading"이면 "다음 쪽이 있다" 는 뜻이다. 목록이 자라거나 300 밖으로 나갔다 다시 오면 또 부를 수 있고,"error"·"end"이거나 "다시 시도" 를 누른 직후에는 부르지 않는다).
데스크톱 목록 — 쪽은 주소에
import { useSearchParams } from "react-router-dom"
import { Pagination } from "@/components/ui/pagination"
const [params] = useSearchParams()
const page = Number(params.get("page") ?? 1)
<section ref={listRef} aria-labelledby="card-list-title">…</section>
{/* 칸은 링크 — 쪽마다 방문 기록이 쌓여 뒤로 가기가 앞 쪽으로 온다 */}
<Pagination totalPages={totalPages} page={page} getHref={(p) => `?page=${p}`} scrollTarget={listRef} aria-label="카드 혜택 페이지 탐색" />폰 목록 — 끝없이 불러오기
import { InfiniteListEnd } from "@/components/ui/pagination"
<ul>{cards.map((c) => <CardRow key={c.id} card={c} />)}</ul>
<InfiniteListEnd
status={isError ? "error" : hasNextPage ? "loading" : "end"}
onReachEnd={fetchNextPage}
onRetry={fetchNextPage}
endText="카드를 모두 봤어요."
/>Behavior
| 인터랙션 | 동작 |
|---|---|
| 번호 누르기 | 그 쪽으로 — 목록 위 끝으로 스크롤, "N페이지, 전체 M페이지" 를 알린다. 칸 자리는 그대로, 생략만 옮겨 간다 |
| 이전 · 다음 누르기 | 앞 · 뒤 쪽으로. 끝 쪽에 닿으면 그 칸이 빈 칸이 되고 초점은 지금 쪽 번호로 |
Tab | 이전 → 번호들 → 다음. 생략 · 빈 칸은 건너뛴다. Enter(링크) · Enter · Space(버튼)로 넘긴다 |
| 마우스 호버 | 누름과 같은 바탕 — 축소 없음 |
| 창 폭이 480 을 넘나듦 | 9칸 ↔ 7칸 |
| 목록을 받는 동안 | 넘김 줄을 막는다(disabled) — 두 번 넘기지 않게 |
| 폰 — 목록 끝이 300 안으로 | 다음 쪽을 받는다 — 목록 끝에 원, 못 받으면 "다시 시도", 끝이면 끝 글 |
Accessibility
| 기준 | 검증 |
|---|---|
| WCAG 1.4.3 Color contrast(글자 ≥ 4.5:1) | 번호 fg-neutral 흰 면 위 16.41 · 다크 13.42 · 회색 페이지 위 15.20 · 15.20, 지금 쪽 fg-neutral-inverted on bg-neutral-inverted 16.41 · 13.42(누름 7.11 · 7.70) ✓. 끝 글 fg-neutral-subtle 5.50 · 6.09 ✓ |
| WCAG 1.4.11 Non-text contrast(≥ 3:1) | 화살표 fg-neutral 16.41 · 13.42 ✓, 지금 쪽 칸 면 16.41 · 13.42 ✓. 키보드 포커스 링 흰 면 위 Desk 8.38 · 6.10 · HR 8.17 · 6.07 · 회색 페이지 위 7.76 · 6.90 · 7.56 · 6.88 ✓. 호버 바탕은 장식(회색 페이지 위에서는 거의 안 보인다 — 축소가 함께 알린다) |
| WCAG 1.4.1 Use of color | 지금 쪽은 채움 면(16.41)으로 알리고 aria-current="page" ✓ |
| WCAG 2.4.3 Focus order | 끝 쪽에서 누르던 화살표가 빈 칸이 되면 초점을 지금 쪽 번호로 — body 로 떨어지지 않는다 ✓ |
| WCAG 4.1.3 Status messages | 쪽이 바뀌면 "N페이지, 전체 M페이지" 를 role="status" 로 ✓ |
| WCAG 2.5.8 Target Size — Minimum(AA ≥ 24×24) | 칸 40 × 44 ✓ |
| WCAG 2.5.5 Target Size — Enhanced(AAA ≥ 44×44) | ⚠ 칸 40 — 위아래만 44, 옆은 40(칸이 붙어 넓히지 않는다). v106 "누르는 영역 44" 의 예외로 둔다(사용자 결정 2026-10-04 — 폰은 끝없이 불러오기). 폰 목록 끝의 "다시 시도" 는 44 ✓ |
| ARIA | <nav aria-label="페이지 탐색">, 칸은 링크(쪽이 주소에 있을 때) 또는 <button type="button"> — 이름 "N페이지", 지금 쪽 aria-current="page", 화살표 "이전 페이지" · "다음 페이지". 생략 · 빈 칸은 aria-hidden. 끝없이 불러오기 — 받는 동안 목록에 aria-busy, 끝 글은 글로 읽힌다 |
Do / Don't
✅ Do
- 데스크톱 목록 아래 가운데 — 칸 40 을 붙이고 지금 쪽은 짙게 채운다.
- 칸 수를 화면 폭으로 고정(9 · 7) — 끝 쪽은 빈 칸으로 자리를 지킨다.
- 쪽을 넘기면 목록 맨 위 + "N페이지" 알림.
- 쪽은 주소에, 칸은 링크.
- 폰은 끝없이 불러오고, 끝 글 · 다시 시도 · 받는 중을 보인다.
- 달처럼 자연스러운 묶음이 있는 목록은 묶음 옮기기를 쪽 넘김으로(가계부 거래 — 달 이동).
❌ Don't
- 지금 쪽을 테두리만 · 브랜드 색으로.
- 칸 사이를 띄우거나 이전 · 다음에 글자.
- 끝 쪽에서 막힌 화살표 · 초점을 잃는 화살표.
- 화면 3 ~ 4개 분량 이하의 목록을 쪽으로 나누기 · 1쪽뿐인 넘김 줄.
- 폰에 쪽 넘김 · 목록 끝에 줄을 덧붙이는 "더 보기" 버튼(카드 · 띠 미리보기는 머리 "전체 보기").
- 표 아래에 이 넘김 줄(표는 Table Pagination).
Specification
pagination.yaml · infinite-list.yaml 의 규칙을 하나도 빼지 않고 조건마다 그린다 — 웹 · 앱이 Pagination 과 목록 끝 자리를 만들 때 이 값을 그대로 쓴다. 조건이 없는 Base 가 모든 조합에 걸리고, 뒤의 규칙이 앞의 같은 값을 덮는다. 상태는 enabled 에서 바뀌는 값만 적었다.
Base
| 부위 | 상태 | 속성 | 값 |
|---|---|---|---|
root | enabled | 위 바깥 여백marginTop | spacing-x624px목록 끝 → 넘김 줄 24(옛 DESIGN.md Pagination 절의 값 — SEED 는 "목록 하단" 만 적었다) |
| 간격gap | 0px칸이 붙는다(SEED) — 9칸이면 360, 7칸이면 280 | ||
| 가로 맞춤justifyContent | center목록 아래 가운데(SEED) | ||
item | enabled | 크기size | 40px40 × 40 — 칸 40 그대로(사용자 결정 2026-10-04) |
| 모서리radius | radius-r28px | ||
| 배경background | transparent누를 때 · 마우스를 올릴 때만 칠한다(지금 쪽은 채움) | ||
| 커서cursor | pointer | ||
| 터치 영역touchTarget | 40 × 44위아래만 44(보이지 않는 여백 2 씩), 옆은 칸 폭 40 — v106 '누르는 영역 44' 의 예외. AA ✓ · AAA ⚠ | ||
| 전환 대상transitionProperty | background-color, scale바탕 색과 누름 축소 둘 다 — 다른 누르는 칸(card · chart …)과 같은 표기, SEED pagination-button 도 colorDuration · scaleDuration 둘(Q-011-6). 시간은 motion 절 | ||
| 전환 시간transitionDuration | motion-duration-color-transition150ms | ||
| 전환 곡선transitionEasing | motion-ease-easingcubic-bezier(0.35, 0, 0.35, 1) | ||
label | enabled | 글자·아이콘foreground | fg-neutral#1A1F2E다크 #F5F6FA |
| 글자typography | text-t414px / 19px | ||
| 굵기fontWeight | 700 | ||
| 숫자 폭numerals | tabular-nums숫자 폭을 같게. 네 자리 이상이 칸을 넘으면 말줄임 — 숫자는 칸 이름에 있다 | ||
arrow | enabled | 크기size | 40px이전 · 다음 — 아이콘만. 누르는 영역은 쪽 번호와 같다(40 × 44) |
| 아이콘 크기iconSize | 16pxlucide chevron-left · chevron-right | ||
| 모서리radius | radius-r28px | ||
| 배경background | transparent | ||
| 색color | fg-neutral#1A1F2E다크 #F5F6FA | ||
| 전환 대상transitionProperty | background-color, scale쪽 번호 칸과 같다(Q-011-6) | ||
| 전환 시간transitionDuration | motion-duration-color-transition150ms | ||
| 전환 곡선transitionEasing | motion-ease-easingcubic-bezier(0.35, 0, 0.35, 1) | ||
ellipsis | enabled | 크기size | 40px |
| 아이콘 크기iconSize | 16pxlucide ellipsis — 보조 기술에 숨긴다, 누르지 않는다 | ||
| 색color | fg-neutral#1A1F2E다크 #F5F6FA | ||
empty | enabled | 크기size | 40px첫 쪽의 이전 · 마지막 쪽의 다음 자리 — 비워 둔다(보조 기술에 숨긴다) |
item | hovered | 배경background | bg-layer-default-pressed#F7F8FD다크 #353B4D누름과 같은 색 — 축소 없음(v106) |
arrow | hovered | 배경background | bg-layer-default-pressed#F7F8FD다크 #353B4D |
item | pressed | 배경background | bg-layer-default-pressed#F7F8FD다크 #353B4DSEED bg.transparent-pressed 의 불투명 짝 |
| 배율scale | 2px 거리 축소기준 40 → 0.95(v104 — SEED 0.97). 모션 줄이기면 하지 않는다 | ||
| 축소 시간scaleDuration | motion-duration-pressed-scale150ms | ||
| 축소 곡선scaleEasing | motion-ease-pressed-scalecubic-bezier(0, 0, 0.15, 1) | ||
arrow | pressed | 배경background | bg-layer-default-pressed#F7F8FD다크 #353B4D |
| 배율scale | 2px 거리 축소 | ||
focusRing | focused | 링 두께width | 2px |
| 링 색color | stroke-focus-ringDesk#0147AD다크 #7AA9F6HR#075B3C다크 #2ABE84 | ||
| 링 간격offset | 2px칸 바깥 — 붙은 이웃 칸 위에 그린다(SEED) | ||
item | disabled | 배경background | transparent |
| 커서cursor | not-allowed | ||
label | disabled | 글자·아이콘foreground | fg-disabled#8A91A0다크 #838997 |
arrow | disabled | 색color | fg-disabled#8A91A0다크 #838997 |
| 커서cursor | not-allowed |
current=current
| 부위 | 상태 | 속성 | 값 |
|---|---|---|---|
item | enabled | 배경background | bg-neutral-inverted#1A1F2E다크 #F5F6FA짙은 채움 — SEED bg.neutral-solid 의 groo 짝(다크는 밝은 반전) |
label | enabled | 글자·아이콘foreground | fg-neutral-inverted#FFFFFF다크 #242938 |
item | hovered | 배경background | bg-neutral-inverted-pressed#535866다크 #B7BDCC |
item | pressed | 배경background | bg-neutral-inverted-pressed#535866다크 #B7BDCCv112 — Button neutralSolid 의 누름과 같다 |
item | disabled | 배경background | bg-disabled#F5F6FA다크 #353B4D흐리게 하지 않는다(v106) |
label | disabled | 글자·아이콘foreground | fg-disabled#8A91A0다크 #838997 |
count=regular
| 부위 | 상태 | 속성 | 값 |
|---|---|---|---|
root | enabled | 너비width | 360px9칸 — 화살표 2 + 번호 · 생략 7. 1 2 3 4 5 … N · 1 … p−1 p p+1 … N · 1 … N−4 N−3 N−2 N−1 N |
count=narrow
| 부위 | 상태 | 속성 | 값 |
|---|---|---|---|
root | enabled | 너비width | 280px7칸 — 화살표 2 + 번호 · 생략 5. 1 2 3 4 … · … p−1 p p+1 … · … N−3 N−2 N−1 N(가운데 구간에서는 첫 · 마지막 번호가 없다) |
Base
| 부위 | 상태 | 속성 | 값 |
|---|---|---|---|
root | enabled | 상하 여백paddingY | spacing-x624px목록 마지막 줄 아래 24 · 그 아래는 화면 바닥 여백(탭 바가 있으면 그 여백) |
| 가로 맞춤justifyContent | center | ||
sentinel | enabled | 문턱threshold | 300px목록 끝이 화면 아래 끝에서 300 안으로 오면 다음 쪽을 받는다(지금 Desk 앱 값). 받는 중이거나 끝이면 부르지 않는다 |
status=loading
| 부위 | 상태 | 속성 | 값 |
|---|---|---|---|
progress | enabled | 크기size | 24pxProgress Circle 24 · neutral — skeleton.md 의 '목록 끝 더 불러오기'. 1초가 지나야 보인다(그 전에 오면 깜빡이지 않는다) |
status=error
| 부위 | 상태 | 속성 | 값 |
|---|---|---|---|
message | enabled | 글자·아이콘foreground | fg-neutral-muted#535866다크 #B7BDCC"더 불러오지 못했어요." — 이미 받은 줄은 그대로 둔다. 문장으로 된 상태 문구라 muted(2026-10-16 사용자 결정 글자 역할 D5 — 그대로 둔다) |
| 글자typography | text-t414px / 19px / 400 | ||
| 굵기fontWeight | 400 | ||
retryButton | enabled | 버튼 크기buttonSize | small 36Button neutralWeak small "다시 시도" — 누르면 그 쪽을 다시 받는다(로딩을 건다) |
| 위 바깥 여백marginTop | spacing-x312px |
status=end
| 부위 | 상태 | 속성 | 값 |
|---|---|---|---|
message | enabled | 글자·아이콘foreground | fg-neutral-subtle#62697A다크 #A2A8B7"모두 봤어요."(목록마다 "카드를 모두 봤어요." 처럼 바꿀 수 있다) — 바탕 위 5.50 · 다크 6.09. 한 쪽만으로 끝나는 짧은 목록에는 두지 않는다 |
| 글자typography | text-t414px / 19px / 400 | ||
| 굵기fontWeight | 400 |
SEED 와 다른 점
- 지금 쪽은
bg-neutral-inverted— SEED bg.neutral-solid(#2A3038)의 groo 짝이다(다크는 밝은 반전). 누름은bg-neutral-inverted-pressed(v112). - 누름 · 호버 바탕은 불투명한
bg-layer-default-pressed· 누름 축소는 2px 거리(40 이면 0.95) — SEED 는 투명한 bg.transparent-pressed · scale 0.97 이다(v102 · v104). - 누르는 영역은 세로만 44 — SEED 는 40 그대로다. 옆은 칸이 붙어 넓히지 않는다(v106 의 예외 — 사용자 결정).
- 끝 쪽에서 초점을 지금 쪽 번호로 — SEED 는 누르던 화살표가 빈 칸이 되어 초점이 body 로 떨어진다.
- 쪽이 주소에 있으면 칸은 링크 — SEED 디자인 문서는 "Page Link" 라 부르지만 코드는 늘 버튼이다.
- 쪽이 바뀌면 넘김 줄이 알린다 — SEED 는 "결과 안내는 목록 영역에서" 로 남겼다.
- 폰은 끝없이 불러오기 — SEED 에는 끝없이 불러오기 · "더 보기" 의 규칙이 없다. groo 가 정했다(받는 시점 300 · 끝 글 · 다시 시도 — 2026-10-04 화면 틀 비교 페이지의 따라오는 것). 미리보기는 머리 "전체 보기", 줄을 덧붙이는 "더 보기" 는 두지 않는다(2026-10-22 사용자 결정 6B).
Migration notes
2026-10-23 — 쪽 넘김은 상황으로
"언제 쪽을 나누나" 의 "데스크톱의 긴 목록 → Pagination · 폰의 긴 목록 → 끝없이 불러오기" 를 동작 상황 표로 바꿨다 — 쪽 단위로 훑는 자리는 Pagination 이 좋을 수 있고, 스크롤로 내려가는 환경(폰 · 긴 목록)은 끝없이 불러오기, 달처럼 자연스러운 묶음이 있는 목록은 묶음 옮기기가 쪽 넘김이다(가계부 거래 — 달 하나 = 한 쪽). DESIGN 세 파일 Layout "화면 틀" 의 문턱 표 "긴 목록" 줄도 "상황에 따라" 로 바꾸고 "목록 넘기기" 표를 더했다. 사용자 결정 — 가계부 다시 짜기 https://claude.ai/artifact/CCAra9mCf1q77pqgM1D2xc 5A(고르지 않은 안 5B). 값 변경 없음.
2026-10-22 — 목록 화면 · 미리보기 둘로
""더 보기" 버튼은 두지 않는다" 는 근거 없이 들어간 문장이라 카드 머리 "전체 보기" 까지 막는 것처럼 읽혔다. 목록 화면의 긴 목록(끝없이 불러오기) · 카드 · 띠 안의 미리보기(머리 "전체 보기")로 가르고, 목록 끝에 줄을 덧붙이는 "더 보기" 만 두지 않는다고 적었다(사용자 결정 6B — https://claude.ai/artifact/NuEPiLRH2fJidXdKzcDsCX). 지금 동작은 그대로다.
2026-10-15 — 구현 세션 질문 정리(Q-011-6)
사용자가 비교 페이지에서 정리 항목을 "추천대로" 정했다(2026-10-15). 쪽 번호 칸 · 화살표 칸의 transitionProperty 를 background-color, scale 로 적었다 — 누를 때 2px 축소가 있으므로 다른 누르는 칸(카드 · 차트 타일)과 같은 표기이고, SEED pagination-button 도 색과 축소 둘을 전환한다. 화살표 칸에는 빠져 있던 전환 시간 · 이징도 쪽 번호 칸과 같게 적었다. 보이는 모습은 바뀌지 않는다 — 웹 · 레시피는 이미 둘 다 전환한다.
2026-10-04 — SEED Pagination 으로 다시 쓴다
사용자가 화면 틀 · 이동 비교 페이지에서 정했다 — SEED Pagination(10A: 번호 · 화살표 40 × 40 · 사이 0 · 14 / 19 · 700 · 모서리 8 · 지금 쪽 bg-neutral-inverted + fg-neutral-inverted 채움 · 이전 · 다음은 아이콘만 16 · 9칸 / 7칸 · 끝 쪽은 화살표 대신 40 빈 칸 + 초점은 지금 쪽 · 1쪽 이하면 그리지 않음) + 데이터 표는 Table Pagination. 같은 날 추가로 — 칸 40 그대로, 누르는 영역 44 규칙(v106)의 예외로 적는다(위아래만 44). 그리고 "따라오는 것" — 폰 긴 목록은 끝없이 불러오기 + 끝 글 · 실패 다시 하기 · 불러오는 동안 표시, 데스크톱은 Pagination, 쪽을 넘기면 목록 맨 위 + 쪽 바뀜 알림. groo 옛 스펙(10B — 지금 쪽 테두리 · 칸 사이 4 · 이전 · 다음 글자 · 끝 막힘 · 좁으면 "이전 · 현재/총 · 다음")과 44 로 키우기는 고르지 않았다. 옛 스펙은 pagination.history/v-pre-seed-nav.*.
제품은 앱 적용 단계에서 옮긴다(2026-10-04 조사 — Desk 웹 · HR 은 크로미움에 띄워 쟀다). 세 제품 모두 숫자 칸 · aria-current · 쪽 바뀜 알림이 0 이다.
- Desk 웹 카드 혜택(데스크톱) — ghost sm 57.9 × 32 "이전 / 다음" + 화살표 14 · 12 / 500 + 가운데 "1 / 3", 끝에서 불투명도 .5(
pages/card-benefit/ui/CardBenefitPage.tsx:676-810). "다음" 이window.scrollTo를 불러 실제로 스크롤하는 셸 상자가 움직이지 않아, 바닥에서 넘기면 2쪽 첫 카드가 −4564 에 있다(:780 · 804↔widgets/layout/ui/AppLayout.tsx:122, F4). 넘김 줄 + 목록 맨 위로. - Desk 웹 · 앱 카드 혜택(폰) — 끝없이 불러오기는 있다(웹 IntersectionObserver · 앱 끝 300 앞 —
card_benefits_screen.dart:121-153 · 300-345). 작은 스피너뿐이고aria-live· 끝 글이 없으며, 앱은 첫 쪽 뒤의 실패를 화면에 보이지 않는다. 목록 끝 자리(받는 중 · 못 불러옴 + 다시 시도 · 끝 글)로. - HR 표 셋 — Pagination 이 아니라 Table Pagination 으로 옮긴다(그 스펙의 Migration notes).
- 옛 스펙의 예시("HR 결재 목록" · "Desk 메모 보관함 142쪽")는 제품에 없다(S2).
Page Banner
페이지 맨 위(머리 바로 아래)에 화면 폭 전체로 놓이는 띠 — 그 페이지 전체의 상태를 알린다(증권 연결 끊김 · Pro 만료 예정 · 새 버전). 한 화면에 하나. 그 기능 가까이의 안내는 Callout, 잠깐 알릴 결과는 Snackbar 다.
Popover
트리거에 붙어 떠 있는 표면. 1280 이상에서 부가 정보 · 짧은 동작 · 고르는 패널(Input Button 의 달력 · 시각 · 목록)을 띄운다 — 같은 내용을 1280 미만에서는 Bottom Sheet 로 띄운다. 비모달이라 뒤 화면을 막지 않는다. 폼 · 상세는 Dialog, 줄의 동작 목록은 Menu(그 차례에), 짧은 도움말 말풍선은 Help Bubble(그 차례에)이다.