View contents
Consistent Navigation Implementation Guide: Building Predictable Navigation Systems
Introduction
Implementing consistent navigation requires careful architectural decisions that ensure navigation elements maintain their order and position across all pages. This guide covers patterns for creating centralized navigation systems, managing navigation state, and testing for consistency.
A well-architected navigation system not only meets accessibility requirements but also simplifies maintenance and reduces the likelihood of inconsistencies.
Centralized Navigation Configuration
Single Source of Truth Pattern
// navigation.config.ts - Central navigation definition
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'
}
];
export const UTILITY_NAVIGATION: NavItem[] = [
{ id: 'search', label: 'Search', href: '/search' },
{ id: 'account', label: 'Account', href: '/account', requiresAuth: true },
{ id: 'cart', label: 'Cart', href: '/cart' }
];
export const FOOTER_NAVIGATION: NavItem[] = [
{ id: 'privacy', label: 'Privacy Policy', href: '/privacy' },
{ id: 'terms', label: 'Terms of Service', href: '/terms' },
{ id: 'accessibility', label: 'Accessibility', href: '/accessibility' }
];
Navigation Service
// services/navigation.service.ts
import { MAIN_NAVIGATION, UTILITY_NAVIGATION, NavItem } from './navigation.config';
export class NavigationService {
private static instance: NavigationService;
private constructor() {}
static getInstance(): NavigationService {
if (!NavigationService.instance) {
NavigationService.instance = new NavigationService();
}
return NavigationService.instance;
}
getMainNavigation(userRoles: string[] = []): NavItem[] {
return this.filterByAuth(MAIN_NAVIGATION, userRoles);
}
getUtilityNavigation(isAuthenticated: boolean): NavItem[] {
return UTILITY_NAVIGATION.filter(item =>
!item.requiresAuth || isAuthenticated
);
}
private filterByAuth(items: NavItem[], userRoles: string[]): NavItem[] {
return items.filter(item => {
if (!item.roles) return true;
return item.roles.some(role => userRoles.includes(role));
}).map(item => ({
...item,
children: item.children
? this.filterByAuth(item.children, userRoles)
: undefined
}));
}
// IMPORTANT: Never reorder navigation
// This method ensures items can be hidden but never reordered
getVisibleNavigation(items: NavItem[], hiddenIds: string[]): NavItem[] {
return items.filter(item => !hiddenIds.includes(item.id));
}
}
React Implementation
Navigation Context
// contexts/NavigationContext.tsx
import React, { createContext, useContext, useMemo } from 'react';
import { MAIN_NAVIGATION, UTILITY_NAVIGATION, NavItem } from '../navigation.config';
interface NavigationContextType {
mainNav: NavItem[];
utilityNav: NavItem[];
currentPath: string;
}
const NavigationContext = createContext<NavigationContextType | null>(null);
interface NavigationProviderProps {
children: React.ReactNode;
currentPath: string;
isAuthenticated?: boolean;
userRoles?: string[];
}
export function NavigationProvider({
children,
currentPath,
isAuthenticated = false,
userRoles = []
}: NavigationProviderProps) {
const mainNav = useMemo(() => {
// Filter by roles but NEVER reorder
return MAIN_NAVIGATION.filter(item => {
if (!item.roles) return true;
return item.roles.some(role => userRoles.includes(role));
});
}, [userRoles]);
const utilityNav = useMemo(() => {
return UTILITY_NAVIGATION.filter(item =>
!item.requiresAuth || isAuthenticated
);
}, [isAuthenticated]);
return (
<NavigationContext.Provider value={{ mainNav, utilityNav, currentPath }}>
{children}
</NavigationContext.Provider>
);
}
export function useNavigation() {
const context = useContext(NavigationContext);
if (!context) {
throw new Error('useNavigation must be used within NavigationProvider');
}
return context;
}
Navigation Component
// components/Navigation.tsx
import React from 'react';
import { useNavigation } from '../contexts/NavigationContext';
import { NavItem } from '../navigation.config';
interface NavLinkProps {
item: NavItem;
isCurrent: boolean;
}
function NavLink({ item, isCurrent }: NavLinkProps) {
return (
<li>
<a
href={item.href}
aria-current={isCurrent ? 'page' : undefined}
className={`nav-link ${isCurrent ? 'nav-link--active' : ''}`}
>
{item.icon && <span className="nav-icon" aria-hidden="true">{item.icon}</span>}
<span>{item.label}</span>
</a>
{item.children && item.children.length > 0 && (
<ul className="nav-submenu" role="menu">
{item.children.map(child => (
<NavLink
key={child.id}
item={child}
isCurrent={child.href === location.pathname}
/>
))}
</ul>
)}
</li>
);
}
export function MainNavigation() {
const { mainNav, currentPath } = useNavigation();
return (
<nav aria-label="Main navigation">
<ul className="nav-list" role="menubar">
{mainNav.map(item => (
<NavLink
key={item.id}
item={item}
isCurrent={item.href === currentPath}
/>
))}
</ul>
</nav>
);
}
export function UtilityNavigation() {
const { utilityNav, currentPath } = useNavigation();
return (
<nav aria-label="Utility navigation">
<ul className="utility-nav">
{utilityNav.map(item => (
<li key={item.id}>
<a
href={item.href}
aria-current={item.href === currentPath ? 'page' : undefined}
>
{item.label}
</a>
</li>
))}
</ul>
</nav>
);
}
Vue 3 Implementation
Navigation Composable
// composables/useNavigation.ts
import { computed, ref, provide, inject, InjectionKey } from 'vue';
import { useRoute } from 'vue-router';
import { MAIN_NAVIGATION, UTILITY_NAVIGATION, NavItem } from '../navigation.config';
interface NavigationState {
mainNav: NavItem[];
utilityNav: NavItem[];
currentPath: string;
}
const NavigationKey: InjectionKey<NavigationState> = Symbol('navigation');
export function provideNavigation(
isAuthenticated = ref(false),
userRoles = ref<string[]>([])
) {
const route = useRoute();
const mainNav = computed(() => {
// Filter by roles but NEVER reorder
return MAIN_NAVIGATION.filter(item => {
if (!item.roles) return true;
return item.roles.some(role => userRoles.value.includes(role));
});
});
const utilityNav = computed(() => {
return UTILITY_NAVIGATION.filter(item =>
!item.requiresAuth || isAuthenticated.value
);
});
const currentPath = computed(() => route.path);
provide(NavigationKey, {
mainNav: mainNav.value,
utilityNav: utilityNav.value,
currentPath: currentPath.value
});
return { mainNav, utilityNav, currentPath };
}
export function useNavigation() {
const navigation = inject(NavigationKey);
if (!navigation) {
throw new Error('useNavigation must be used within provideNavigation');
}
return navigation;
}
Vue Navigation Component
<!-- components/MainNavigation.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { MAIN_NAVIGATION, type NavItem } from '../navigation.config'
const route = useRoute()
const isCurrentPage = (href: string) => {
return route.path === href
}
</script>
<template>
<nav aria-label="Main navigation">
<ul class="nav-list" role="menubar">
<li
v-for="item in MAIN_NAVIGATION"
:key="item.id"
role="none"
>
<a
:href="item.href"
:aria-current="isCurrentPage(item.href) ? 'page' : undefined"
:class="['nav-link', { 'nav-link--active': isCurrentPage(item.href) }]"
role="menuitem"
>
{{ item.label }}
</a>
<!-- Submenu -->
<ul
v-if="item.children?.length"
class="nav-submenu"
role="menu"
>
<li
v-for="child in item.children"
:key="child.id"
role="none"
>
<a
:href="child.href"
:aria-current="isCurrentPage(child.href) ? 'page' : undefined"
role="menuitem"
>
{{ child.label }}
</a>
</li>
</ul>
</li>
</ul>
</nav>
</template>
<style scoped>
.nav-list {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.nav-link {
display: block;
padding: 0.75rem 1rem;
text-decoration: none;
color: inherit;
}
.nav-link--active {
font-weight: 600;
border-bottom: 2px solid currentColor;
}
.nav-submenu {
position: absolute;
top: 100%;
left: 0;
list-style: none;
background: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: none;
}
li:hover > .nav-submenu,
li:focus-within > .nav-submenu {
display: block;
}
</style>
Mobile Navigation Consistency
Responsive Navigation Hook
// hooks/useResponsiveNavigation.ts
import { useState, useEffect, useCallback } from 'react';
import { MAIN_NAVIGATION, NavItem } from '../navigation.config';
export function useResponsiveNavigation() {
const [isMobile, setIsMobile] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// CRITICAL: Same navigation items, same order
// Only the presentation changes, not the content or order
const navigation = MAIN_NAVIGATION;
const toggleMenu = useCallback(() => {
setIsMenuOpen(prev => !prev);
}, []);
return {
isMobile,
isMenuOpen,
toggleMenu,
navigation // Same items regardless of viewport
};
}
Mobile Menu Component
// components/MobileNavigation.tsx
import React from 'react';
import { useResponsiveNavigation } from '../hooks/useResponsiveNavigation';
export function MobileNavigation() {
const { isMobile, isMenuOpen, toggleMenu, navigation } = useResponsiveNavigation();
if (!isMobile) return null;
return (
<>
<button
onClick={toggleMenu}
aria-expanded={isMenuOpen}
aria-controls="mobile-menu"
aria-label={isMenuOpen ? 'Close menu' : 'Open menu'}
className="mobile-menu-toggle"
>
<span className="hamburger-icon" aria-hidden="true" />
</button>
<nav
id="mobile-menu"
aria-label="Main navigation"
className={`mobile-nav ${isMenuOpen ? 'mobile-nav--open' : ''}`}
hidden={!isMenuOpen}
>
<ul className="mobile-nav-list">
{/* SAME order as desktop navigation */}
{navigation.map(item => (
<li key={item.id}>
<a href={item.href}>{item.label}</a>
{item.children && (
<ul className="mobile-submenu">
{item.children.map(child => (
<li key={child.id}>
<a href={child.href}>{child.label}</a>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
</>
);
}
CSS for Consistent Layout
/* Ensure navigation position is consistent across pages */
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: white;
border-bottom: 1px solid #e5e5e5;
}
.nav-container {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* Navigation always in same position */
.main-nav {
order: 2; /* After logo */
}
.utility-nav {
order: 3; /* After main nav */
}
/* Mobile: Stack navigation but maintain order within each */
@media (max-width: 767px) {
.nav-container {
flex-wrap: wrap;
}
.main-nav {
order: 3; /* Moves below header on mobile */
width: 100%;
}
/* Items within nav maintain same order */
.main-nav .nav-list {
flex-direction: column;
}
}
Automated Testing
Playwright Tests for Consistent Navigation
import { test, expect } from '@playwright/test';
test.describe('Consistent Navigation', () => {
const pagesToTest = ['/', '/products', '/about', '/contact'];
test('navigation order is consistent across pages', async ({ page }) => {
const navOrders = [];
for (const url of pagesToTest) {
await page.goto(url);
// Get navigation item order
const navItems = await page.locator('nav[aria-label="Main navigation"] a').allTextContents();
navOrders.push(navItems);
}
// All pages should have same navigation order
const firstOrder = navOrders[0];
for (let i = 1; i < navOrders.length; i++) {
expect(navOrders[i]).toEqual(firstOrder);
}
});
test('navigation position is consistent', async ({ page }) => {
const navPositions = [];
for (const url of pagesToTest) {
await page.goto(url);
const nav = page.locator('nav[aria-label="Main navigation"]');
const boundingBox = await nav.boundingBox();
if (boundingBox) {
navPositions.push({
top: Math.round(boundingBox.y),
left: Math.round(boundingBox.x)
});
}
}
// Check position consistency (allow small variations)
const firstPosition = navPositions[0];
for (let i = 1; i < navPositions.length; i++) {
expect(Math.abs(navPositions[i].top - firstPosition.top)).toBeLessThan(5);
expect(Math.abs(navPositions[i].left - firstPosition.left)).toBeLessThan(5);
}
});
test('utility navigation order is consistent', async ({ page }) => {
const utilityOrders = [];
for (const url of pagesToTest) {
await page.goto(url);
const utilityItems = await page.locator('nav[aria-label="Utility navigation"] a').allTextContents();
utilityOrders.push(utilityItems);
}
const firstOrder = utilityOrders[0];
for (let i = 1; i < utilityOrders.length; i++) {
expect(utilityOrders[i]).toEqual(firstOrder);
}
});
test('footer navigation order is consistent', async ({ page }) => {
const footerOrders = [];
for (const url of pagesToTest) {
await page.goto(url);
const footerItems = await page.locator('footer nav a').allTextContents();
footerOrders.push(footerItems);
}
const firstOrder = footerOrders[0];
for (let i = 1; i < footerOrders.length; i++) {
expect(footerOrders[i]).toEqual(firstOrder);
}
});
test('mobile navigation has same order as desktop', async ({ page }) => {
// Desktop navigation order
await page.setViewportSize({ width: 1200, height: 800 });
await page.goto('/');
const desktopNav = await page.locator('nav[aria-label="Main navigation"] a').allTextContents();
// Mobile navigation order
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
// Open mobile menu if needed
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();
// Same order
expect(mobileNav).toEqual(desktopNav);
});
});
Summary Checklist
- [ ] Navigation defined in single source of truth
- [ ] Same navigation items across all pages
- [ ] Same order maintained across all pages
- [ ] Navigation position consistent across pages
- [ ] Mobile navigation matches desktop order
- [ ] Only user-initiated changes alter navigation
- [ ] Proper aria-labels on navigation regions
- [ ] aria-current marks active page
- [ ] Automated tests verify consistency
Related Articles
- Consistent Navigation Explained - WCAG 3.2.3 requirements
- Skip Links Guide - Bypass blocks
- Keyboard Navigation Guide - Full keyboard support
- WCAG Compliance Hub - All accessibility evaluators
References
- W3C - WCAG 2.2 SC 3.2.3 Consistent Navigation
- W3C - G61 Presenting repeated components in the same order
- W3C - Navigation landmark pattern
- WebAIM - Navigation
- MDN - Navigation role