Lesson 11 / 25
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.
Contents
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.
// 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-currentdeclaration 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.