iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
自我挑戰組

30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System系列 第 19 篇

Day 19: Pagination:上一頁下一頁也是 Navigation

  • 分享至 

  • xImage
  •  

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

1. Pagination 到底是 Button 還是 Link?

開始做 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。


2. 今天先採用 Navigation Pattern

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。


3. 加入 shadcn/ui Pagination

先建立 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>

4. Pagination 最外層為什麼是 <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。


5. 為什麼需要 aria-label?

頁面可能不只有一個 <nav>。

例如:

Main Navigation
Breadcrumb
Pagination
Footer Navigation

如果全部都只是:

<nav>

使用 Landmark Navigation 的使用者可能只會得到:

Navigation
Navigation
Navigation

很難知道哪一個是什麼。

所以 Pagination 可以:

<nav aria-label="分頁">

如果頁面上有兩個 Pagination,也可以更具體:

<nav aria-label="申請紀錄分頁">

這樣就更容易辨認。


6. CUI Pagination Contract

延續前面的 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。


7. Current Page 不能只靠背景色

假設現在第二頁:

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。


8. isActive 與 aria-current

shadcn/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 就能保持一致。


9. 不要寫成 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。

要先確認元件真正的語意。


10. Previous / Next 不應該只有 Icon

桌面版 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。


11. Icon 一樣不是資訊本身

這裡延續 Day 9 和 Day 17 的原則。

例如:

<ChevronLeft aria-hidden="true" />

Icon 只是視覺提示。

真正的意思是:

上一頁

所以:

Icon
→ 看起來更容易理解

Text / Accessible Name
→ 真正的資訊

不要讓使用者必須知道:

左箭頭 = 上一頁

才能操作 Pagination。


12. Ellipsis 是什麼?

當頁數很多時,不可能:

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"

因為它不是互動元件。


13. Ellipsis 的文字資訊

視覺上:

…

很容易理解成「還有更多」。

但可以提供一段 visually hidden text:

<span aria-hidden="true">
  …
</span>

<span className="sr-only">
  更多頁面
</span>

這樣視覺:

…

而輔助科技可以取得:

更多頁面

如果 shadcn/ui 原本已經有類似處理,就可以保留,不需要重複再加。


14. 第一個 CUI Pagination

假設現在在第 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"

15. 第一頁時,「上一頁」怎麼辦?

假設目前:

Page 1

就沒有:

Page 0

那 Previous 要怎麼處理?

這裡有幾種策略。


方法一:不 Render

第一頁:

1 2 3 4 … 10  下一頁

直接沒有 Previous。

可以。

但不同頁面之間 Pagination Layout 會稍微改變。


方法二:顯示 Disabled Previous

例如:

← 上一頁   1 2 3 4 … 10   下一頁 →

但上一頁不可操作。

如果它是 Button:

<button disabled>

很好處理。

但如果它是:

<a>

HTML <a> 並沒有 disabled attribute。

不能單純寫:

<a disabled>

然後期待它像 Button 一樣失效。


16. Disabled Link 要特別小心

如果 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 實作都可能不同。


17. CUI Pagination 不管理資料

這點跟昨天 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 的責任。


18. Legacy 反而很好處理

這也是我們一直保留:

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

19. Pagination 和 Table 組起來

現在終於可以把 Day 18 + Day 19 組在一起:

申請紀錄

┌──────────────────────────────────────┐
│ 姓名    申請項目    狀態    更新時間 │
├──────────────────────────────────────┤
│ 王小明  學分抵免    已通過  10/01   │
│ 陳小華  休學申請    待審核  10/01   │
│ 林小美  獎學金申請  已退回  09/30   │
└──────────────────────────────────────┘

        <  1  [2]  3  4  …  10  >

這時已經開始很像真正的後台頁面。

而不是:

Button Demo
Input Demo
Badge Demo

這種元件展示頁。


20. Pagination 的 Focus 不需要自己管理

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。


21. Current Page 還需要可以點嗎?

假設目前在 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

22. Pagination 不一定需要顯示所有頁碼

假設:

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 />

現在先不要過度設計。


23. Pagination 的 Responsive

桌面可能是:

上一頁  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 可以改變視覺呈現,但不能順便把資訊一起刪掉。


24. Pagination 不是「數字選擇器」

今天做到這裡,我覺得 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

更符合真正的語意。


25. Accessibility Check

今天最後檢查:

Navigation

  • Pagination 使用 <nav>
  • Navigation 有清楚的 aria-label
  • 頁碼使用符合行為的 Link / Button

Current Page

目前頁面使用:

aria-current="page"

而不是只靠:

Background Color
Border
Font Weight

Previous / Next

如果只有 Icon:

aria-label="上一頁"
aria-label="下一頁"

Icon 本身:

aria-hidden="true"

Ellipsis

如果不能互動:

不要加入 Tab Order
不要假裝成 Button

必要時提供:

更多頁面

的非視覺文字。

Keyboard

使用原生 Link / Button behavior。

不要自己重新實作不必要的 Keyboard Navigation。


26. Keyboard Test

最後真的用鍵盤走一次:

Tab
 ↓
上一頁
 ↓
Page 1
 ↓
Page 2
 ↓
Page 3
 ↓
Page 4
 ↓
Page 10
 ↓
下一頁

確認:

  • Focus indicator 清楚
  • Current Page 可以辨認
  • Ellipsis 不會取得 Focus
  • Disabled control 不會產生奇怪的操作
  • Icon-only control 仍有 Accessible Name

27. 最後執行檢查

完成後:

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"

Day 19 完成

今天表面上做的是:

< 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 最重要的其中一步,就是讓這兩件事不要互相矛盾。


現在我們已經有 Data Display 的基本材料

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

各自存在。

下一步要開始思考:

它們組在一起之後,誰負責什麼?


Next:Day 20

下一篇我們會第一次把前面做的元件正式組成一個比較完整的 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:元件開始真的拿來做系統。


上一篇
Day 18: Table:資料列表不是用一堆 `<div>` 排整齊就好
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言