View contents
Introduction
Consistent navigation is a Level AA accessibility requirement defined in WCAG 2.2 under success criterion 3.2.3. The criterion states that navigation mechanisms repeated across multiple pages within a site must appear in the same relative order every time they are presented, unless the user initiates the change.
As people move through a site, they build a mental model of where things are. A menu whose order changes between pages forces them to re-learn the interface on every visit, which is especially costly for people with cognitive disabilities, for screen reader users who memorise the structure, and for people with motor disabilities who rely on muscle memory to navigate efficiently.
Implementing consistent navigation takes architectural decisions: centralise the menu definition, guarantee the same order on every page, and hold the visual position stable regardless of what each page contains.
Why navigation consistency matters
The cost of inconsistent navigation goes well beyond compliance. Usability studies put task completion between 40% and 60% slower when navigation changes position or order between pages. For assistive technology users the impact is larger still: a screen reader walks elements sequentially, so when the menu structure shifts, the user loses their point of reference.
The mistakes that break consistency most often are:
- Dynamic reordering: Algorithms that reorder menu items by popularity or by the context of the current page.
- Selective hiding that does not preserve order: Removing menu items on certain pages in a way that shifts the relative position of the remaining ones.
- Different navigation on mobile and desktop: Presenting items in a different order in the hamburger menu than in the desktop menu.
- Injecting dynamic items: Adding promotional or contextual items that displace the existing ones.
The underlying rule is simple: items may be hidden, but the ones that stay visible must always keep the same relative order.
Single source of truth pattern
Consistent navigation starts with defining the menu structure in one place and having every interface component consume it from there.
Central navigation definition
This pattern uses a configuration file as the canonical source. Any change to the navigation structure happens in that file and nowhere else, which is what guarantees every page reflects the same structure.
// navigation.config.ts - single source of truth
export interface NavItem {
id: string;
label: string;
href: string;
icon?: string;
children?: NavItem[];
requiresAuth?: boolean;
roles?: string[];
}
export const MAIN_NAVIGATION: NavItem[] = [
{ id: 'home', label: 'Home', href: '/' },
{
id: 'products',
label: 'Products',
href: '/products',
children: [
{ id: 'electronics', label: 'Electronics', href: '/products/electronics' },
{ id: 'clothing', label: 'Clothing', href: '/products/clothing' },
{ id: 'accessories', label: 'Accessories', href: '/products/accessories' }
]
},
{ id: 'about', label: 'About', href: '/about' },
{ id: 'contact', label: 'Contact', href: '/contact' }
];
The important part of this pattern is that the array defines the canonical order. No component should reorder these items. Components may filter — hiding items by authentication or role — and JavaScript's filter() preserves the original array order, so consistency comes for free.
Navigation service with safe filtering
The navigation service wraps the filtering logic so that items can never be reordered:
export class NavigationService {
// Filters by authorisation but NEVER reorders
getMainNavigation(userRoles: string[] = []): NavItem[] {
return this.filterByAuthorisation(MAIN_NAVIGATION, userRoles);
}
private filterByAuthorisation(items: NavItem[], roles: string[]): NavItem[] {
return items.filter(item => {
if (!item.roles) return true;
return item.roles.some(role => roles.includes(role));
}).map(item => ({
...item,
children: item.children
? this.filterByAuthorisation(item.children, roles)
: undefined
}));
}
}
Note that it uses filter() and map(), never sort(). That restriction is deliberate: a developer who needs a different order has to change the source array in navigation.config.ts, not the filtering logic.
Navigation component implementation
Correct semantic markup
A <nav> element with a descriptive aria-label is what lets screen readers identify navigation regions. When a site has several navigation blocks — main, utility, footer — each needs its own unique aria-label to be told apart.
export function MainNavigation() {
const { mainNav, currentPath } = useNavigation();
return (
<nav aria-label="Main navigation">
<ul role="menubar">
{mainNav.map(item => (
<li key={item.id} role="none">
<a
href={item.href}
role="menuitem"
aria-current={item.href === currentPath ? 'page' : undefined}
>
{item.label}
</a>
</li>
))}
</ul>
</nav>
);
}
The markup decisions that matter:
aria-current="page"marks the active page both visually and for assistive technology, without touching the menu structure.role="menubar"androle="menuitem"give the menu semantics screen readers recognise as a navigation pattern.role="none"on the<li>strips the list semantics so the screen reader treats the links directly as menu items.
Consistency between desktop and mobile
One of the most common failures is presenting navigation items in a different order on mobile. The hamburger menu must contain exactly the same items in the same order as the desktop navigation. Only the visual presentation changes — never the content or the sequence.
export function useResponsiveNavigation() {
const [isMobile, setIsMobile] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
// CRITICAL: same items, same order in both versions
// 'navigation' is always MAIN_NAVIGATION
const navigation = MAIN_NAVIGATION;
return { isMobile, menuOpen, navigation };
}
The mobile menu button needs its accessibility attributes wired correctly:
aria-expandedreports whether the menu is open or closed.aria-controlsties the button to the navigation panel it controls.aria-labelgives an accessible name that changes with state ("Open menu" / "Close menu").
CSS for consistent position
The menu's visual position has to be consistent too. A menu that sits at the top on some pages and to the side on others violates the spirit of 3.2.3. Use position: sticky to anchor the navigation and CSS order to keep the layout predictable:
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: white;
border-bottom: 1px solid #e5e5e5;
}
/* Navigation always in the same relative position */
.main-nav { order: 2; }
.utility-nav { order: 3; }
/* Mobile: change the layout, keep the internal order */
@media (max-width: 767px) {
.main-nav .nav-list {
flex-direction: column;
}
}
Automated consistency testing
Consistent navigation is one of the easiest accessibility criteria to automate. The tests need to cover three things: same item order, same visual position, and a match between the desktop and mobile versions.
test('navigation order is identical across pages', async ({ page }) => {
const pages = ['/', '/products', '/about', '/contact'];
const orders = [];
for (const url of pages) {
await page.goto(url);
const items = await page
.locator('nav[aria-label="Main navigation"] a')
.allTextContents();
orders.push(items);
}
// Compare every page against the first
for (let i = 1; i < orders.length; i++) {
expect(orders[i]).toEqual(orders[0]);
}
});
test('mobile navigation matches desktop order', async ({ page }) => {
await page.setViewportSize({ width: 1200, height: 800 });
await page.goto('/');
const desktopNav = await page
.locator('nav[aria-label="Main navigation"] a')
.allTextContents();
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
const menuButton = page.locator('[aria-controls="mobile-menu"]');
if (await menuButton.isVisible()) await menuButton.click();
const mobileNav = await page
.locator('nav[aria-label="Main navigation"] a')
.allTextContents();
expect(mobileNav).toEqual(desktopNav);
});
These Playwright tests belong in the CI pipeline, where they catch regressions automatically every time the navigation structure changes.
Checklist
- [ ] Navigation defined in a single source of truth (a centralised configuration file)
- [ ] The same navigation items present on every page
- [ ] The same relative order kept on every page
- [ ] Navigation's visual position consistent between pages
- [ ] Mobile navigation in the same order as the desktop version
- [ ] Only user-initiated changes alter the navigation
- [ ] Every
<nav>block has a descriptive, uniquearia-label - [ ]
aria-current="page"marks the active page - [ ] Automated tests verify order and position consistency
Frequently asked questions
Can I hide menu items based on user role?
Yes. Hiding items is acceptable as long as the remaining ones keep their original relative order. Use filter() rather than sort() so the source array's order is preserved.
Does criterion 3.2.3 apply to the footer and secondary navigation?
Yes. It applies to any navigation mechanism repeated across multiple pages, including main navigation, utility navigation, breadcrumbs and footer links.
What if my mobile menu groups items differently?
Grouping visually is allowed — accordions, for instance — but the order of items within each group and between groups must match the desktop version. The content and the sequence do not change; only the presentation does.
How do I handle navigation items that appear only in certain sections?
Contextual submenus, such as a section sidebar, are acceptable as long as the global main navigation stays consistent. Contextual items should be clearly separated with a distinct aria-label.
Related Articles
- Consistent Navigation Explained - WCAG 3.2.3 requirements
- Keyboard Navigation Guide - Full keyboard support
- WCAG Compliance Hub - All accessibility checks