Day 18,我們完成了 CUI 的第一個 Table。
現在資料終於可以用真正具有語意的方式呈現:
姓名 申請項目 狀態 更新時間
王小明 學分抵免 已通過 2026/10/01
陳小華 休學申請 待審核 2026/10/01
林小美 獎學金申請 已退回 2026/09/30
但真實系統裡,資料通常不會只有三筆。
可能是:
237 筆
1,284 筆
甚至更多
當資料量增加,我們通常不會一次把全部內容塞進頁面,而是:
< 上一頁 1 2 3 4 5 下一頁 >
也就是今天要做的:
Pagination
看起來好像只是幾顆 Button 排在一起。
但如果從 Accessibility 的角度來看,Pagination 其實不是普通的 Button Group。
它代表的是:
一組用來瀏覽不同資料頁面的 Navigation。
今天預計完成:
Pagination
│
├── Pagination
├── PaginationContent
├── PaginationItem
├── PaginationLink
├── PaginationPrevious
├── PaginationNext
└── PaginationEllipsis
並處理:
<nav>
aria-label
aria-current="page"
Previous / Next
Icon accessible name
Current Page
Disabled State
Ellipsis
最後組出:
上一頁 1 2 3 4 5 下一頁
↑
Current
開始做 Pagination 前,第一個問題其實不是 CSS。
而是:
頁碼應該用
<button>還是<a>?
答案取決於它到底在做什麼。
假設:
/page?page=1
/page?page=2
/page?page=3
每一頁都有自己的 URL。
那頁碼本質上就是:
前往另一個頁面。
這時 <a> 很合理:
<a href="?page=2">
2
</a>
但如果是完全 Client-side 的 Data Table:
React state
↓
setPage(2)
↓
重新顯示第二批資料
並沒有真正 Navigation 到另一個 URL。
這時:
<button type="button">
2
</button>
也可能更符合行為。
所以:
Pagination
↓
不是看到頁碼就一律 Button
也不是看到 Pagination 就一律 Link
要先看它真正的 Interaction。
CUI 目前的 Pagination 先以最典型的:
Page Navigation
為主要 Pattern。
因此結構會接近:
<nav aria-label="分頁">
<ul>
<li>
<a href="?page=1">
1
</a>
</li>
<li>
<a
href="?page=2"
aria-current="page"
>
2
</a>
</li>
</ul>
</nav>
這裡已經不只是:
一排連結
而是:
nav
└── Pagination Links
輔助科技可以理解:
這是一組 Navigation。
先建立 branch:
git switch main
git pull
git switch -c feat/pagination
加入 Pagination:
npx shadcn@latest add pagination
通常會產生:
src/components/ui/pagination.tsx
裡面包含:
Pagination
PaginationContent
PaginationItem
PaginationLink
PaginationPrevious
PaginationNext
PaginationEllipsis
這次和 Table 一樣,不需要 Base UI。
因為 HTML 本身已經有我們需要的語意:
<nav>
<ul>
<li>
<a>
<nav>?如果只是:
<div>
<a>1</a>
<a>2</a>
<a>3</a>
</div>
視覺上當然可以正常使用。
但 Pagination 本質上是在:
導航到資料的不同頁面。
所以更適合:
<nav>
例如:
<nav
aria-label="分頁"
data-cui-slot="pagination"
>
HTML:
<nav aria-label="分頁">
這樣 Pagination 就會成為頁面裡的一個 Navigation Landmark。
aria-label?頁面可能不只有一個 <nav>。
例如:
Main Navigation
Breadcrumb
Pagination
Footer Navigation
如果全部都只是:
<nav>
使用 Landmark Navigation 的使用者可能只會得到:
Navigation
Navigation
Navigation
很難知道哪一個是什麼。
所以 Pagination 可以:
<nav aria-label="分頁">
如果頁面上有兩個 Pagination,也可以更具體:
<nav aria-label="申請紀錄分頁">
這樣就更容易辨認。
延續前面的 Component Contract:
<nav
data-slot="pagination"
data-cui-slot="pagination"
>
其他元件:
data-cui-slot="pagination-content"
data-cui-slot="pagination-item"
data-cui-slot="pagination-link"
data-cui-slot="pagination-previous"
data-cui-slot="pagination-next"
data-cui-slot="pagination-ellipsis"
未來 Legacy 可以寫:
<nav
data-cui-slot="pagination"
aria-label="分頁"
>
...
</nav>
而 React 與 Legacy 共用同一套 CUI styling contract。
假設現在第二頁:
1 [2] 3 4 5
最直覺的作法可能是:
2 → 藍色背景
其他 → 白色背景
但這又回到了 Day 17 的問題:
如果使用者無法辨識這個視覺差異呢?
HTML 本身提供了非常適合的 attribute:
aria-current="page"
例如:
<a
href="?page=2"
aria-current="page"
>
2
</a>
意思就是:
這個 Link 代表目前所在的 Page。
因此:
Visual
→ 背景色 / Border
Semantic
→ aria-current="page"
兩邊一起表達 Current State。
isActive 與 aria-currentshadcn/ui 的 PaginationLink 通常會有:
isActive
例如:
<PaginationLink
href="?page=2"
isActive
>
2
</PaginationLink>
我們可以讓 isActive 同時控制:
Visual Style
+
aria-current
概念上:
aria-current={isActive ? "page" : undefined}
這樣:
isActive
不只是:
幫我變一個顏色。
而是:
這是目前頁面。
Component state 和 Accessibility state 就能保持一致。
aria-selected這裡很容易誤用:
aria-selected="true"
Pagination 的 Current Page 並不是:
Tab
Listbox Option
Grid Cell
這類 Selection Widget。
它代表的是:
Current Page。
因此應該使用:
aria-current="page"
而不是:
aria-selected="true"
這也是 ARIA 很重要的一點:
不要因為「看起來像選中了」就隨便選一個 state。
要先確認元件真正的語意。
桌面版 Pagination 很可能是:
← Previous
Next →
但在空間比較小的設計裡,也可能變成:
← →
如果只寫:
<ChevronLeft />
Screen Reader 不一定知道:
這是上一頁。
因此 Icon-only control 必須有 Accessible Name。
例如:
<a
href="?page=1"
aria-label="上一頁"
>
<ChevronLeft aria-hidden="true" />
</a>
這時:
ChevronLeft
→ Decorative
上一頁
→ Accessible Name
如果畫面本身已經有文字:
<a href="?page=1">
<ChevronLeft aria-hidden="true" />
<span>上一頁</span>
</a>
那文字本身就可以提供 Accessible Name。
這裡延續 Day 9 和 Day 17 的原則。
例如:
<ChevronLeft aria-hidden="true" />
Icon 只是視覺提示。
真正的意思是:
上一頁
所以:
Icon
→ 看起來更容易理解
Text / Accessible Name
→ 真正的資訊
不要讓使用者必須知道:
左箭頭 = 上一頁
才能操作 Pagination。
當頁數很多時,不可能:
1 2 3 4 5 6 7 8 9 10 11 12 ... 100
全部列出來。
通常會:
1 2 3 4 5 … 100
這個:
…
就是 Pagination Ellipsis。
它通常只是:
中間還有其他頁面沒有顯示。
如果 Ellipsis 本身不能點擊,就不應該讓它:
tabIndex={0}
也不需要:
role="button"
因為它不是互動元件。
視覺上:
…
很容易理解成「還有更多」。
但可以提供一段 visually hidden text:
<span aria-hidden="true">
…
</span>
<span className="sr-only">
更多頁面
</span>
這樣視覺:
…
而輔助科技可以取得:
更多頁面
如果 shadcn/ui 原本已經有類似處理,就可以保留,不需要重複再加。
假設現在在第 2 頁:
<Pagination aria-label="申請紀錄分頁">
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="?page=1" />
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=1">
1
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink
href="?page=2"
isActive
>
2
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=3">
3
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=4">
4
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=10">
10
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext href="?page=3" />
</PaginationItem>
</PaginationContent>
</Pagination>
畫面:
< 上一頁 1 [2] 3 4 … 10 下一頁 >
而 Current Page 同時具有:
aria-current="page"
假設目前:
Page 1
就沒有:
Page 0
那 Previous 要怎麼處理?
這裡有幾種策略。
第一頁:
1 2 3 4 … 10 下一頁
直接沒有 Previous。
可以。
但不同頁面之間 Pagination Layout 會稍微改變。
例如:
← 上一頁 1 2 3 4 … 10 下一頁 →
但上一頁不可操作。
如果它是 Button:
<button disabled>
很好處理。
但如果它是:
<a>
HTML <a> 並沒有 disabled attribute。
不能單純寫:
<a disabled>
然後期待它像 Button 一樣失效。
如果 Pagination 使用 Link Navigation:
<PaginationPrevious href="...">
第一頁時比較乾淨的策略可能是:
不提供 href
+
aria-disabled="true"
+
阻止 interaction
或者乾脆:
不 Render Previous Link
具體選擇要依 Router / Framework 的使用方式決定。
這也是為什麼我不想讓 CUI Pagination 一開始就自己管理:
currentPage
totalPages
onPageChange
因為:
React Router
Next.js
普通 <a>
SPA State
Legacy Server Render
每個環境的 Navigation 實作都可能不同。
這點跟昨天 Table 一樣重要。
Pagination Component 負責:
Layout
Semantic structure
Current state
Previous / Next UI
Accessible labels
但它目前不負責:
fetch()
API
currentPage state
totalPages calculation
URL Query String
Router
也就是:
Pagination
≠
Pagination Logic
例如:
<PaginationLink href="?page=3">
3
</PaginationLink>
CUI 只負責這個 Link 的:
外觀
Current state
Accessibility
至於:
?page=3
如何產生,是 Application Layer 的責任。
這也是我們一直保留:
data-cui-*
的原因。
未來 Legacy 可以直接:
<nav
data-cui-slot="pagination"
aria-label="申請紀錄分頁"
>
<ul data-cui-slot="pagination-content">
<li data-cui-slot="pagination-item">
<a
data-cui-slot="pagination-link"
href="?page=1"
>
1
</a>
</li>
<li data-cui-slot="pagination-item">
<a
data-cui-slot="pagination-link"
href="?page=2"
aria-current="page"
>
2
</a>
</li>
</ul>
</nav>
完全不需要 React 才能擁有正確的 Pagination semantics。
這也是 CUI 一開始希望達成的:
React
│
├── Component API
│
↓
CUI Contract
↑
│
Legacy HTML
現在終於可以把 Day 18 + Day 19 組在一起:
申請紀錄
┌──────────────────────────────────────┐
│ 姓名 申請項目 狀態 更新時間 │
├──────────────────────────────────────┤
│ 王小明 學分抵免 已通過 10/01 │
│ 陳小華 休學申請 待審核 10/01 │
│ 林小美 獎學金申請 已退回 09/30 │
└──────────────────────────────────────┘
< 1 [2] 3 4 … 10 >
這時已經開始很像真正的後台頁面。
而不是:
Button Demo
Input Demo
Badge Demo
這種元件展示頁。
Pagination 是由普通 Link 組成:
<a>
所以瀏覽器本身就已經知道:
Tab
↓
Previous
↓
1
↓
2
↓
3
↓
Next
不需要自己做:
onKeyDown
然後攔:
ArrowLeft
ArrowRight
也不需要自己實作:
Roving Tabindex
因為這不是:
Tabs
Menu
Radio Group
這類 Composite Widget。
Pagination 就是一組 Navigation Links。
不要在原生 HTML 已經能處理的地方重新實作 Keyboard Behavior。
假設目前在 Page 2:
1 [2] 3
Page 2 要不要仍然是一個 Link?
兩種方式都可能看到。
例如:
<a
href="?page=2"
aria-current="page"
>
2
</a>
仍然可以點。
或 Current Page 不提供 Navigation。
CUI 現階段不需要強制其中一種。
比較重要的是:
目前頁面
必須能被辨識。
因此至少要正確表達:
aria-current="page"
而不是只靠:
藍色背景
粗體
Border
假設:
totalPages = 237
絕對不會:
1 2 3 4 5 6 7 8 ... 237
全部 render。
可能是:
1 2 3 4 5 … 237
或目前在 120:
1 … 118 119 [120] 121 122 … 237
但:
頁碼範圍怎麼計算?
今天先不放進 CUI Pagination Component。
因為這屬於:
Pagination Logic
而不是:
Pagination UI Primitive
之後如果大量專案都需要同一種邏輯,再考慮抽成:
getPaginationRange()
甚至:
<DataPagination />
現在先不要過度設計。
桌面可能是:
上一頁 1 2 3 4 5 … 20 下一頁
手機可能塞不下。
這時可以考慮:
< 1 [2] 3 … 20 >
也就是視覺上隱藏:
上一頁
下一頁
只留下 Chevron。
但如果文字隱藏,Accessible Name 仍然必須存在。
例如:
<a aria-label="上一頁">
<ChevronLeft aria-hidden="true" />
</a>
這樣:
Visual
←
Accessible Name
上一頁
Responsive 可以改變視覺呈現,但不能順便把資訊一起刪掉。
今天做到這裡,我覺得 Pagination 最容易被誤解的地方就是:
1 2 3 4 5
看起來很像:
一組可以選的數字
所以很容易開始想到:
selected
option
radio
但它真正代表的是:
Page 1
Page 2
Page 3
Page 4
Page 5
也就是:
Navigation destinations。
因此:
<nav>
<a>
aria-current="page"
會比:
role="radiogroup"
aria-selected
更符合真正的語意。
今天最後檢查:
<nav>
aria-label
目前頁面使用:
aria-current="page"
而不是只靠:
Background Color
Border
Font Weight
如果只有 Icon:
aria-label="上一頁"
aria-label="下一頁"
Icon 本身:
aria-hidden="true"
如果不能互動:
不要加入 Tab Order
不要假裝成 Button
必要時提供:
更多頁面
的非視覺文字。
使用原生 Link / Button behavior。
不要自己重新實作不必要的 Keyboard Navigation。
最後真的用鍵盤走一次:
Tab
↓
上一頁
↓
Page 1
↓
Page 2
↓
Page 3
↓
Page 4
↓
Page 10
↓
下一頁
確認:
完成後:
npm run build
npm run lint
npm run check:contrast
確認:
git status
git diff
最後:
git add src/components/ui/pagination.tsx src/App.tsx
git commit -m "feat: add accessible pagination component"
今天表面上做的是:
< 1 2 3 4 5 >
但真正建立的是:
Pagination
↓
Navigation
↓
Current Page
↓
Accessible Navigation
而今天最重要的一個觀念就是:
先問這個 UI「在做什麼」,再決定它應該使用什麼 HTML 和 ARIA。
如果它是在前往不同頁面:
<a>
如果它是在執行 Client-side action:
<button>
如果它代表目前所在頁面:
aria-current="page"
而不是因為:
「它長得像一排按鈕。」
就全部做成 Button。
這和之前做的很多元件其實都是同一件事:
看起來像什麼
≠
它真正是什麼
Accessible UI 最重要的其中一步,就是讓這兩件事不要互相矛盾。
Day 17:
Badge
Day 18:
Table
Day 19:
Pagination
於是現在已經可以組出:
申請紀錄
[ Search........................ ]
┌────────────────────────────────────┐
│ 姓名 項目 狀態 日期 │
├────────────────────────────────────┤
│ 王小明 學分抵免 [已通過] 10/01 │
│ 陳小華 休學申請 [待審核] 10/01 │
│ 林小美 獎學金 [已退回] 09/30 │
└────────────────────────────────────┘
< 1 [2] 3 4 … 10 >
這已經非常接近真正的系統頁面了。
但現在這些東西仍然只是:
Search
Table
Badge
Pagination
各自存在。
下一步要開始思考:
它們組在一起之後,誰負責什麼?
下一篇我們會第一次把前面做的元件正式組成一個比較完整的 Pattern:
Data List
│
├── Search
├── Result Count
├── Table
│ └── Badge
└── Pagination
並開始處理:
Search Label
Search Result
Result Count
Table + Pagination relationship
Responsive Layout
Component responsibility
也就是從:
「我做了一套元件。」
正式往:
「我可以拿這套元件做系統。」
前進。
Day 20:
組出第一個 Data List:元件開始真的拿來做系統。