---
title: 'Breadcrumb and Pagination'
source: 'https://academia.sh/en/courses/accessible-patterns/breadcrumbs-and-pagination'
course: 'Accessible Component Patterns'
language: en
updated: '2026-08-19T05:19:51+00:00'
license: 'CC BY-SA 4.0'
---

# Breadcrumb and Pagination

Declaring hierarchical and sequential position in markup, the link name carrying its target, keeping the current page as a link, and the focus decision on a page change.

The containers in the previous lesson showed and hid content; none of them told the user
where they are. In the catalog, position is information: the user is three levels down a
subject branch and on the fourth page of the results list. The two components that carry
this information are the breadcrumb and pagination, and both do the same job —
separating the current location from the rest.

Both share the same difficulty. Visually, the current location is marked by a color or
weight difference; that difference tells a screen-reader user nothing. When the
distinction is not written in markup, the component turns into a list of look-alike
links.

## The Problem the Pattern Solves

The breadcrumb declares position in the **hierarchy**, pagination declares position in
the **sequence**. The breadcrumb is also a path upward: the user returns three levels up
in a single step. Pagination says a set has been split into parts and states which part
is being viewed.

Neither is the only way to navigate. The breadcrumb does not replace primary navigation;
pagination does not replace infinite scroll. Both are an **additional** layer of
information, and that is why both sit in their own navigation region.

## The Native Element First

Both components are built from native elements: a navigation region, a list inside it,
links inside the list. In the breadcrumb the list is **ordered**, because the order of
the items carries meaning. In pagination the order is meaningful too, but the items sit
at an equal level.

If a page holds more than one navigation region, each needs a name; two unnamed regions
cannot be told apart. The name states what the region is for: "Breadcrumb," "Result
pages." The current location is declared with `aria-current` — the last item in the
breadcrumb, the current page number in pagination.

The computation below builds both components on a small document model and computes
their accessible names.

```js
// position.mjs — breadcrumb and pagination: accessible name, current-location marker, and focus

// A small document model: a node tree and accessible name computation.
const node = (tag, attrs = {}, children = []) => ({ tag, attrs, children });
const text = (d) =>
  d.tag === "#text" ? d.attrs.value : d.children.map(text).join("").replace(/\s+/g, " ").trim();
const txt = (s) => node("#text", { value: s });

// Accessible name priority: aria-label > aria-labelledby > content text.
function accessibleName(d, doc) {
  if (d.attrs["aria-label"]) return d.attrs["aria-label"];
  if (d.attrs["aria-labelledby"]) {
    const target = find(doc, d.attrs["aria-labelledby"]);
    return target ? text(target) : "";
  }
  return text(d);
}
function find(d, id) {
  if (d.attrs.id === id) return d;
  for (const c of d.children) { const s = find(c, id); if (s) return s; }
  return null;
}
const walk = function* (d) { yield d; for (const c of d.children) yield* walk(c); };

// 1. Breadcrumb: navigation region + ordered list + current-page marker.
const breadcrumb = node("nav", { "aria-label": "Breadcrumb" }, [
  node("ol", {}, [
    node("li", {}, [node("a", { href: "/catalog" }, [txt("Catalog")])]),
    node("li", {}, [node("a", { href: "/catalog/science" }, [txt("Science")])]),
    node("li", {}, [node("a", { href: "/catalog/science/astronomy" }, [txt("Astronomy")])]),
    node("li", {}, [node("span", { "aria-current": "page" }, [txt("Northern Slope Observation Records")])]),
  ]),
]);

// 2. Pagination: navigation region + links + current page.
const pagination = node("nav", { "aria-label": "Result pages" }, [
  node("ul", {}, [
    node("li", {}, [node("a", { href: "?page=3", "aria-label": "Previous page" }, [txt("‹")])]),
    node("li", {}, [node("a", { href: "?page=1" }, [txt("1")])]),
    node("li", {}, [node("span", { "aria-hidden": "true" }, [txt("…")])]),
    node("li", {}, [node("a", { href: "?page=3" }, [txt("3")])]),
    node("li", {}, [node("a", { href: "?page=4", "aria-current": "page" }, [txt("4")])]),
    node("li", {}, [node("a", { href: "?page=5" }, [txt("5")])]),
    node("li", {}, [node("a", { href: "?page=5", "aria-label": "Next page" }, [txt("›")])]),
  ]),
]);

function audit(name, root) {
  console.log(`\n--- ${name} ---`);
  console.log(`region name        : ${accessibleName(root, root) || "(none)"}`);
  const current = [...walk(root)].filter((d) => d.attrs["aria-current"] === "page");
  console.log(`current-page marker: ${current.length} element` +
    (current.length === 1 ? ` ("${text(current[0])}")` : ""));
  const links = [...walk(root)].filter((d) => d.tag === "a");
  console.log("link names         :");
  for (const l of links) {
    const n = accessibleName(l, root);
    const warning = n.length <= 2 && !l.attrs["aria-label"] ? "  <- name is a single character, does not state the target" : "";
    console.log(`  ${(l.attrs.href || "").padEnd(24)} name: ${JSON.stringify(n).padEnd(22)}${warning}`);
  }
  const hidden = [...walk(root)].filter((d) => d.attrs["aria-hidden"] === "true").length;
  console.log(`decorative elements hidden from the tree: ${hidden}`);
  return { current: current.length, links: links.length };
}

audit("breadcrumb", breadcrumb);
audit("pagination", pagination);

// 3. Focus on page change: three options and their outcomes.
// Model: a keyboard user presses the link to page 4, the list re-renders.
const OPTIONS = [
  { name: "focus untouched", focus: "the pressed link", loss: false, announcement: "none" },
  { name: "focus to top of list", focus: "results list container", loss: false, announcement: "list heading" },
  { name: "focus to top of page", focus: "document root", loss: true, announcement: "page heading" },
];
console.log("\n--- focus decision on page change ---");
console.log("option                   where focus goes          lost place  announcement");
for (const o of OPTIONS) {
  console.log(
    o.name.padEnd(24) + o.focus.padEnd(26) + (o.loss ? "yes" : "no").padEnd(12) + o.announcement
  );
}

// 4. What happens if the current page is left as a link?
// How many stops are in the tab order, and how many target the same page?
function tabOrder(root) {
  return [...walk(root)].filter((d) => d.tag === "a" && d.attrs.href !== undefined);
}
const s1 = tabOrder(pagination);
const sameTarget = s1.filter((d) => d.attrs["aria-current"] === "page").length;
console.log(`\ntab stops in pagination: ${s1.length}`);
console.log(`stops that also leave the current page as a link: ${sameTarget}`);
console.log(`  -> leaving the current page as a link sends the user to the same page; ` +
  `the marker is preserved, the target does not change.`);

// 5. Breadcrumb length: name length as depth grows, and the truncation rule.
const TRUNCATE = 18;
console.log("\n--- truncating breadcrumb elements ---");
for (const d of [...walk(breadcrumb)].filter((x) => ["a", "span"].includes(x.tag))) {
  const full = text(d);
  const shown = full.length > TRUNCATE ? full.slice(0, TRUNCATE - 1) + "…" : full;
  console.log(
    `  shown: ${JSON.stringify(shown).padEnd(32)} accessible name: ${JSON.stringify(full)}` +
      (shown !== full ? "  <- shown text truncated, accessible name must stay full" : "")
  );
}
```

```
--- breadcrumb ---
region name        : Breadcrumb
current-page marker: 1 element ("Northern Slope Observation Records")
link names         :
  /catalog                 name: "Catalog"             
  /catalog/science         name: "Science"             
  /catalog/science/astronomy name: "Astronomy"           
decorative elements hidden from the tree: 0

--- pagination ---
region name        : Result pages
current-page marker: 1 element ("4")
link names         :
  ?page=3                  name: "Previous page"       
  ?page=1                  name: "1"                     <- name is a single character, does not state the target
  ?page=3                  name: "3"                     <- name is a single character, does not state the target
  ?page=4                  name: "4"                     <- name is a single character, does not state the target
  ?page=5                  name: "5"                     <- name is a single character, does not state the target
  ?page=5                  name: "Next page"           
decorative elements hidden from the tree: 1

--- focus decision on page change ---
option                   where focus goes          lost place  announcement
focus untouched         the pressed link          no          none
focus to top of list    results list container    no          list heading
focus to top of page    document root             yes         page heading

tab stops in pagination: 6
stops that also leave the current page as a link: 1
  -> leaving the current page as a link sends the user to the same page; the marker is preserved, the target does not change.

--- truncating breadcrumb elements ---
  shown: "Catalog"                        accessible name: "Catalog"
  shown: "Science"                        accessible name: "Science"
  shown: "Astronomy"                      accessible name: "Astronomy"
  shown: "Northern Slope Ob…"             accessible name: "Northern Slope Observation Records"  <- shown text truncated, accessible name must stay full
```

## A Name Has to State the Target

The second block of the output shows pagination's most common defect: the links'
accessible name is a single character. A link named "3" tells a user browsing the link
list nothing. `aria-label` was written for the arrow marks, but not for the number
links, though the problem is the same in both.

The fix is completing the accessible name without changing the shown text: the link's
name becomes not "3" but "Page 3." The visual presentation shows the number, the name
states what the number is. The same rule works in reverse for decorative elements: the
ellipsis in pagination carries no information and is hidden from the tree; the count of
hidden elements in the second block of output confirms this.

## Should the Current Page Stay a Link

The third and fourth blocks measure a design decision. If the current page is left as a
link, the tab order gains one more stop, and that stop sends the user to the same page.
If it is not, the stop count drops, but the current page's position becomes invisible in
the keyboard order.

The decision can be tied to a measure: the current page **stays a link** and is marked
with `aria-current`. This way, the marker declaration is preserved and no gap opens in
the sequence; the target being the same page is not a loss, because the user is already
there and the marker says so.

## Where Focus Goes on a Page Change

The table in the third block compares three options. Not touching focus at all is the
least intrusive, but nothing announces that new content has arrived. Moving focus to the
top of the page causes a loss of place: the user has to scroll back down to the
pagination control.

The third option — moving focus to the results list's container — solves both problems:
the container becomes focusable, its heading is read, and the user starts at the
beginning of the new content. Because the pagination control sits right below the list,
moving to the next page is still a short path.

## A Long Breadcrumb Name

The last block shows the rule for visual truncation. A long record name can be shortened
in the breadcrumb; truncation is a **visual** decision and must not affect the accessible
name. When the shown text is truncated, the full name is preserved as the accessible
name; otherwise the user never learns the record's actual name.

## Keyboard Contract

| Key | Behavior |
|---|---|
| `Tab` | Moves to the next link; both components are link lists, no extra key rule applies |
| `Shift+Tab` | Returns to the previous link |
| `Enter` | Follows the link |

Neither component has an arrow-key rule. Tabs and menus needed the arrow keys because
they were each a single composite control; the breadcrumb and pagination are lists of
independent links, and the expected behavior in a link list is `Tab`.

## Measurable Constraints

- Pagination links' target size must be at least 24×24 pixels on touch input (WCAG
  2.5.8); the spacing between them is the second way to meet this measure.
- The current page cannot be distinguished by color alone (WCAG 1.4.1): a weight, a
  border, or another channel accompanies the color, and the `aria-current` declaration
  already provides the textual channel.
- The breadcrumb separator (`/`, `>`) is decorative and is hidden from the accessibility
  tree; if the separator becomes part of the name text, the name is polluted.
- Every navigation region must have an accessible name (structural information under
  WCAG 1.3.1).

## Common Mistake

The most common mistake is marking the current page only visually. The second is
building pagination with buttons and never updating the address bar: the user cannot
share the fourth page, and the back button does not return to the previous page. A page
number is **position information**, and position is carried in the address — this is the
interface-side counterpart of the rule established in the Route Parameters and Query
lesson.

## Summary

- The breadcrumb declares position in the hierarchy, pagination in the sequence; both sit
  in their own named navigation region and mark the current location with
  `aria-current`.
- The accessible name has to state the target: "Page 3," not "3"; the decorative
  separator and ellipsis are hidden from the tree.
- The current page stays a link and stays marked; taking it out of the sequence makes
  position invisible to a keyboard user.
- On a page change, focus moves to the results list's container: there is no loss of
  place, and the arrival of new content is announced.
- Visual truncation does not shorten the accessible name; the full name is preserved in
  the tree.

## Next Step

With this lesson, the results list can declare its position, but the list itself is still
a plain sequence of records. When the same data turns into a table in the catalog —
columns, sorting, filters, and hundreds of rows — position information grows to two axes:
the user is in a row **and** a column at once. The next lesson specifies the table: the
relationship between header cells and rows and columns, how sort state is announced, and
which shortcuts dense data requires.
