Detailed guide

Consistent Navigation Implementation Guide

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' }
];
// 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

// 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;
}
// 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

// 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

References

  1. W3C - WCAG 2.2 SC 3.2.3 Consistent Navigation
  2. W3C - G61 Presenting repeated components in the same order
  3. W3C - Navigation landmark pattern
  4. WebAIM - Navigation
  5. MDN - Navigation role

Related articles

Related version

Introduction

Consistent Navigation Explained

WCAG 3.2.3 (Consistent Navigation) requires that navigational mechanisms that are repeated on multiple pages within a set of web pages occur in the same...

Category hub

Hub

Wcag Compliance Hub

Web accessibility ensures that websites and applications can be used by everyone, including people with disabilities