Build an Accessible Breadcrumb
Are you in the right place? If you are constrained to a third-party component library, see the Breadcrumbs Accessibility Checklist and ensure that it passes. Otherwise, follow the guidance here to choose the best option for your project.
Use the NYSDS Breadcrumbs Web Component
The NYSDS Breadcrumbs is a web component you can drop into your project and use right away. For most teams, this is the simplest way to achieve an accessible component. This means teams don’t need to manually implement accessibility or test for it.
What's already handled for you:-
Keyboard Navigation
Tab, Enter, Space, and Arrows work as intended
-
Screen reader support
Tested with NVDA, JAWS, and VoiceOver
-
Focus management
Focus stays where users expect it
-
Voice control
Supports Windows and macOS
-
Zoom magnification
Works correctly at 200% browser zoom
-
WCAG 2.2 AA conformant
Meets New York State standards
Alternatives
For projects not ready to adopt the Design System Whether you seek to remediate an existing component or build one from scratch, these reference implementations will ensure an accessible outcome. For more details, read about this pattern.
Custom Breadcrumb ("symbol")
When to use If your team has source-level control over UI code, but DOES have a hard requirement not provided by native HTML, you may choose to build a custom Breadcrumb. Or perhaps your legacy component has been flagged for remediation and you need a high quality reference. This custom example recreates the accessibility built into the NYSDS Breadcrumbs.
How is it different This Breadcrumb expresses the look-and-feel of the design system with most aspects modeled after the NYSDS Tab component. The concept of "collapsed links," and the implementation of a "revealer" button to show them, differentiate this Breadcrumb from a simple list of links. The significant accessibility implications of this design choice are discussed in About this pattern.
About the first demo On this page there are two demos of the same component. A "Reveal links/Collapse links" button is provided to reset each demo when playing with it. This button is not part of the Breadcrumb component.
-
This first Breadcrumb provides a "symbol" appearance (three dots "...") for the revealer button,
which has the addressable
aria-label"show more links" - It terminates with the non-linked "current page"
-
It gains an accessible name by being
aria-labelledbytheh4heading above it, which also provides a visually hidden context hint
Custom Breadcrumb ("text")
About the second demo
-
This second Breadcrumb provides a "text" appearance ("More...") for the revealer button,
which also has the addressable
aria-label"show more links" - Initially it terminates with a linked "parent page," but adds the final, non-linked, current page when the revealer is activated
-
Its accessible name comes from an
aria-labelset on the componentnav, which also provides a context hint
/**
* NYSA11y Breadcrumb
* Path: /assets/nysa11y/breadcrumb-custom.css
* Depends on /assets/nysa11y/breadcrub-custom.js
*/
@layer nysa11y {
body:has(.nysa11y) {
nav.nysa11y.breadcrumb[data-component="breadcrumb"][data-html="custom"] {
--nysa11y-breadcrumb-bg-default: transparent;
--nysa11y-breadcrumb-pad-inline: 4px;
*,
*:before,
*:after {
box-sizing: border-box;
}
background-color: var(--nysa11y-breadcrumb-bg-default);
color: var(--nys-color-text-weak, #4a4d4f);
font-family:
"Proxima Nova",
-apple-system,
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"Helvetica",
"Arial",
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol";
font-size: 1rem;
padding-block: 0.75rem;
padding-inline: calc(2rem - var(--nysa11y-breadcrumb-pad-inline));
&.background {
background-color: var(--nys-color-theme-faint, #f7fafd);
}
& ol {
background-color: inherit;
display: flex;
flex-wrap: wrap;
font-size: inherit;
list-style: none;
margin: 0 !important; /* defensive */
padding-inline: 0;
row-gap: 0.75rem;
& li {
align-items: center;
background-color: var(--nysa11y-breadcrumb-bg-default);
display: flex;
font-size: inherit;
height: 1.5rem;
margin: 0 !important; /* defensive */
padding-inline: var(--nysa11y-breadcrumb-pad-inline);
white-space: nowrap;
&:has(a, button) {
padding-inline: 0;
&::after {
align-self: baseline;
content: "\276F" / "";
display: flex;
justify-content: center;
width: 1.5rem;
font-size: 0.9rem;
}
}
& a,
& button {
align-items: center;
align-self: stretch;
background-color: transparent;
border-radius: 0.25rem;
color: var(--nys-color-text-weak, #4a4d4f) !important; /* defensive */
cursor: pointer;
font-size: inherit;
line-height: 1.5rem;
min-width: 1.5rem;
padding-inline: 4px;
&:focus-visible {
outline: solid var(--nys-border-width-md, 2px) var(--nys-color-focus, #004dd1);
outline-offset: var(--nys-border-width-md, 2px);
}
}
& a {
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-skip-ink: auto;
text-decoration-thickness: 14%;
text-underline-offset: auto;
}
&:active {
color: var(--nys-color-neutral-900, #1b1b1b) !important;
}
}
}
}
&[data-revealer] button {
display: flex;
justify-content: center;
}
&[data-revealer="symbol"] {
& button.revealer {
border: none;
&::after {
content: "\2026" / "";
font-size: 1.2rem;
}
&:hover {
text-decoration: underline;
text-decoration-thickness: 1.2px;
text-underline-offset: 2px;
&:active {
text-decoration-thickness: 1.8px;
}
}
}
}
&[data-revealer="text"] {
& button.revealer {
background-color: var(--nys-color-surface, #ffffff);
border: 1.5px solid var(--nys-color-theme, #154973);
color: var(--nys-color-theme, #154973) !important; /* defensive */
cursor: pointer;
margin-inline: var(--nysa11y-breadcrumb-pad-inline);
&::after {
content: "More..." / "";
font-size: 0.85rem;
}
&:hover,
&:focus-visible {
background-color: var(--nys-color-theme-weaker, #eff6fb);
}
&:active {
background-color: var(--nys-color-theme-weak, #cddde9);
color: var(--nys-color-neutral-900, #1b1b1b) !important;
}
}
}
}
}
}
/**
* NYSA11y Breadcrumb
* Path: /assets/nysa11y/breadcrumb-custom.js
* Depends on /assets/nysa11y/breadcrub-custom.css
*/
class Breadcrumb {
// Private fields for internal state and DOM elements
#container;
#navElement;
#revealer;
#intermediateItems;
#observer;
#isButtonClicked = false;
/**
* Initializes the Breadcrumb instance.
* @param {Object} options Configuration options.
* @param {HTMLElement} options.container The root <nav> element of the breadcrumb component.
*/
constructor(options = {}) {
this.#container = options.container || document;
this.#init();
}
/**
* Sets up the component's elements, event listeners, and initial state.
*/
#init() {
this.#navElement = this.#container.querySelector('nav[data-component="breadcrumb"]') || this.#container;
this.#revealer = this.#navElement.querySelector('[data-part="revealer"]');
this.#intermediateItems = this.#navElement.querySelectorAll(".intermediate");
if (!this.#revealer) {
console.error(
`Breadcrumb component (ID: ${this.#navElement.id || "unknown"}) is missing 'data-part="revealer"' element.`,
);
return;
}
// Bind the event listener to the revealer button
const revealerButton = this.#revealer.querySelector("button");
if (revealerButton) {
revealerButton.addEventListener("click", () => {
this.#isButtonClicked = true;
// Mutate DOM
this.maximize();
});
}
// Use a MutationObserver to watch for changes to the 'data-state' attribute
this.#observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === "attributes" && mutation.attributeName === "data-state") {
this.render();
}
}
if (this.#isButtonClicked) {
// Reset flag immediately
this.#isButtonClicked = false;
// Perform action only if button triggered.
// Why? Because rerender has to complete before we move focus.
// Why are we managing focus? Because the focused element, the button,
// is about to be `display:none`, and we MUST NOT lose focus back to <body>.
requestAnimationFrame(() => {
// When changing to 'max', focus on the zeroth intermediate item
if (this.#intermediateItems.length > 0) {
// Find a focusable element within the first intermediate item (e.g., a link)
const focusableElement = this.#intermediateItems[0].querySelector("a");
if (focusableElement) {
focusableElement.focus();
}
}
});
}
});
this.#observer.observe(this.#navElement, { attributes: true });
// Initial render and make the nav visible
this.render();
this.#navElement.style.display = "block";
}
/**
* Sets 'data-state' to 'max'.
*/
maximize() {
this.#navElement.setAttribute("data-state", "max");
}
/**
* Sets 'data-state' to 'min'.
*/
minimize() {
this.#navElement.setAttribute("data-state", "min");
}
/**
* Renders the component based on the current 'data-state'.
*/
render() {
const state = this.#navElement.getAttribute("data-state");
if (state === "min") {
this.#intermediateItems.forEach((item) => {
item.style.display = "none";
});
this.#revealer.style.display = "flex";
} else if (state === "max") {
this.#intermediateItems.forEach((item) => {
item.style.display = "flex";
});
this.#revealer.style.display = "none";
} else {
console.warn(`Invalid 'data-state' value: ${state}`);
}
}
/**
* Static method to initialize all matching nav elements on the page.
*/
static initializeAll() {
document.querySelectorAll('nav[data-component="breadcrumb"]').forEach((navElement) => {
new Breadcrumb({ container: navElement });
});
}
}
// Initialize all breadcrumbs when the DOM is ready
document.addEventListener("DOMContentLoaded", () => {
Breadcrumb.initializeAll();
});
About this pattern
A Breadcrumb is a navigation region containing a set of one or more anchor links to different pages, structured in a hierarchy. This hierarchy typically extends from the site home page or web application entry point to the current page or its immediate parent. Rather than an outline, like side navigation, or a tree, like a site map, a Breadcrumb is like a ladder with discrete linear steps between beginning and end. A Breadcrumb makes it easier to understand complex sites, particularly when users arrive at a page from search results or from a different website. A Breadcrumb can orient users when a deep site hierarchy is not made adequately clear by other navigation cues.
Key points
Communicate the nature and purpose of a Breadcrumb with explicit semantics
(nav or role="navigation") and informative
naming (aria-label) or association (aria-labelledby).
The established visual convention for a Breadcrumb is a horizontal list of links separated by a text or graphic glyph.
If a glyph indicates visual direction
it must change direction accordingly
when the page is rendered for right-to-left (dir="rtl") locales.
Note that glyphs are also subject to level AA requirements for text color contrast.
Breadcrumb links must follow all accessibility rules for links.
- They must be distinguished from body text
- They must take focus and indicate focus
- They must be activated by Enter as well as by mouse click, tap, and voice command
- They must navigate to a new page or application view
- This destination must be adequately represented by the link name
Breadcrumb separator glyphs must be hidden from screen readers (three techniques shown below). If glyphs are coded within their own list item, hide the entire item.
Very commonly a Breadcrumb displays at small font sizes.
Ensure that link tap targets respect an absolute minimum dimension of 24 pixels square
or equivalent non-interactive horizontal and vertical space between them.
This can be done with CSS gap or line-height or padding, or other means.
When the final Breadcrumb item echoes the name of the current page, render it as plain text, not as a link. This avoids redundancy and potential confusion that navigation is still necessary or possible.
Interactivity Our non-scientific expectation is that a Breadcrumb will more often be read for orientation than activated for navigation. Considerations of additional interactivity should weigh effort against likely usage. An existing paradigm, followed by the NYSDS Breadcrumbs, is to hide a certain number of Breadcrumb links when certain viewport rules and component configuration options are in effect. In their place a button is provided, that when clicked reveals the hidden links and then hides itself from interaction, or even deletes itself from the DOM entirely.
Considering accessibility, focus management is the first challenge with a dynamic revealer button. Upon activation, the revealer — which has focus at the time of activation — is itself hidden. At that moment focus will be lost unless it is explicitly set, in this case on the first link that is newly revealed.
Full disclosure: the voice control case The second challenge is activation of the revealer by voice control. The two main systems, Windows Voice Access (WVA) and macOS Voice Control (MVC), both allow activation of elements by grid number and by name or partial name. When using the NYSDS Breadcrumbs, WVA users can invoke the entire string "Show more links" or any one of "show," "more," "links." As of last test, MVC was not so capable.
Given that the current revealer button implementation is graphical rather than text, we do not have a way to easily discover the button's addressable name. A tooltip is a possibility, though not a recommendation, as it would add yet more complexity to a component primarily used for display. Activation by name should be the faster experience, but if the name is not discoverable the slower identification by number must be used. In practice this may be only marginally slower, and anyway is now the only available option under macOS.
Clearly, such a simple-seeming functional addition dramatically increases technical and accessibility considerations, so approach such interaction design decisions with great care.
Best Practices The NYSDS Breadcrumb component implements several of these best practices.
- Allow Breadcrumb lists to wrap, and do not truncate link item labels.
- A link someone cannot see is one they cannot use.
- A link someone cannot understand or distinguish from another is one they may be afraid to use.
- Within a semantic navigation region, use a semantic, ordered list.
- This will communicate the number and index of links to screen reader users.
- Ensure the smallest tap target is 24 pixels square with at least that same amount of non-interactive space between them.
- This helps all touch device users to make the selections they intend.
-
If the final Breadcrumb item must be a redundant link to the current page,
mark it up with the attribute
aria-current="page". - This best identifies the current item within a container or set of related elements.
- If a "revealer" button is implemented, give it a visible label, such as "more."
- This aids all users in understanding and manipulation.
- Take structured data into account when building and integrating a Breadcrumb.
- Correct configuration directly influences search engine optimization, which makes your content easier to find for all users. See Google's Breadcrumblist structured data documentation.