Skip to main content
Back to Blog
Tutorials
4 min read
December 9, 2024

How to Create Accessible Navigation Patterns in React

Build accessible navigation components with proper ARIA roles, keyboard support, focus management, and skip links in React.

Ryel Banfield

Founder & Lead Developer

Good navigation is accessible by default — keyboard navigable, screen reader friendly, and properly labeled. Here are the patterns you need.

Skip Link

The most important accessibility pattern. Lets keyboard users bypass navigation.

// components/SkipLink.tsx
export function SkipLink() {
  return (
    <a
      href="#main-content"
      className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md focus:outline-none"
    >
      Skip to main content
    </a>
  );
}

// In your layout:
// <SkipLink />
// <Navbar />
// <main id="main-content" tabIndex={-1}>...

Accessible Main Navigation

"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useRef, useEffect } from "react";

interface NavItem {
  label: string;
  href: string;
  children?: { label: string; href: string }[];
}

const navItems: NavItem[] = [
  { label: "Home", href: "/" },
  {
    label: "Services",
    href: "/services",
    children: [
      { label: "Web Design", href: "/services/web-design" },
      { label: "Web Development", href: "/services/web-development" },
      { label: "Mobile Apps", href: "/services/mobile-apps" },
    ],
  },
  { label: "About", href: "/about" },
  { label: "Contact", href: "/contact" },
];

export function AccessibleNav() {
  const pathname = usePathname();

  return (
    <nav aria-label="Main navigation">
      <ul role="menubar" className="flex items-center gap-1">
        {navItems.map((item) =>
          item.children ? (
            <DropdownMenuItem key={item.href} item={item} pathname={pathname} />
          ) : (
            <li key={item.href} role="none">
              <Link
                href={item.href}
                role="menuitem"
                aria-current={pathname === item.href ? "page" : undefined}
                className={`px-3 py-2 rounded-md text-sm ${
                  pathname === item.href
                    ? "bg-primary text-primary-foreground"
                    : "hover:bg-muted"
                }`}
              >
                {item.label}
              </Link>
            </li>
          )
        )}
      </ul>
    </nav>
  );
}

Dropdown Menu with Keyboard Support

function DropdownMenuItem({
  item,
  pathname,
}: {
  item: NavItem;
  pathname: string;
}) {
  const [open, setOpen] = useState(false);
  const menuRef = useRef<HTMLUListElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const isActive = item.children?.some((child) => pathname === child.href);

  // Close on Escape
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === "Escape" && open) {
        setOpen(false);
        buttonRef.current?.focus();
      }
    }
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [open]);

  // Close on click outside
  useEffect(() => {
    function handleClick(e: MouseEvent) {
      if (
        menuRef.current &&
        !menuRef.current.contains(e.target as Node) &&
        !buttonRef.current?.contains(e.target as Node)
      ) {
        setOpen(false);
      }
    }
    document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, []);

  // Arrow key navigation within dropdown
  function handleMenuKeyDown(e: React.KeyboardEvent) {
    const items = menuRef.current?.querySelectorAll<HTMLAnchorElement>(
      '[role="menuitem"]'
    );
    if (!items) return;

    const currentIndex = Array.from(items).indexOf(
      document.activeElement as HTMLAnchorElement
    );

    if (e.key === "ArrowDown") {
      e.preventDefault();
      const next = (currentIndex + 1) % items.length;
      items[next]?.focus();
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      const prev = (currentIndex - 1 + items.length) % items.length;
      items[prev]?.focus();
    } else if (e.key === "Home") {
      e.preventDefault();
      items[0]?.focus();
    } else if (e.key === "End") {
      e.preventDefault();
      items[items.length - 1]?.focus();
    }
  }

  return (
    <li role="none" className="relative">
      <button
        ref={buttonRef}
        role="menuitem"
        aria-haspopup="true"
        aria-expanded={open}
        onClick={() => setOpen(!open)}
        onKeyDown={(e) => {
          if (e.key === "ArrowDown") {
            e.preventDefault();
            setOpen(true);
            // Focus first item after render
            requestAnimationFrame(() => {
              menuRef.current
                ?.querySelector<HTMLAnchorElement>('[role="menuitem"]')
                ?.focus();
            });
          }
        }}
        className={`flex items-center gap-1 px-3 py-2 rounded-md text-sm ${
          isActive ? "bg-primary text-primary-foreground" : "hover:bg-muted"
        }`}
      >
        {item.label}
        <svg
          className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`}
          fill="none"
          viewBox="0 0 24 24"
          stroke="currentColor"
          aria-hidden="true"
        >
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
        </svg>
      </button>

      {open && (
        <ul
          ref={menuRef}
          role="menu"
          aria-label={`${item.label} submenu`}
          onKeyDown={handleMenuKeyDown}
          className="absolute top-full left-0 mt-1 w-48 bg-background border rounded-md shadow-lg py-1 z-50"
        >
          {item.children?.map((child) => (
            <li key={child.href} role="none">
              <Link
                href={child.href}
                role="menuitem"
                aria-current={pathname === child.href ? "page" : undefined}
                onClick={() => setOpen(false)}
                className={`block px-4 py-2 text-sm ${
                  pathname === child.href
                    ? "bg-primary/10 font-medium"
                    : "hover:bg-muted"
                }`}
              >
                {child.label}
              </Link>
            </li>
          ))}
        </ul>
      )}
    </li>
  );
}

Mobile Navigation with Focus Trap

"use client";

import { useEffect, useRef, useState } from "react";

export function MobileNav() {
  const [open, setOpen] = useState(false);
  const navRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (!open) return;

    // Trap focus within mobile nav
    const nav = navRef.current;
    if (!nav) return;

    const focusableElements = nav.querySelectorAll<HTMLElement>(
      'a[href], button, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === "Escape") {
        setOpen(false);
        triggerRef.current?.focus();
        return;
      }

      if (e.key !== "Tab") return;

      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement?.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement?.focus();
        }
      }
    }

    document.addEventListener("keydown", handleKeyDown);
    firstElement?.focus();

    // Prevent body scroll
    document.body.style.overflow = "hidden";

    return () => {
      document.removeEventListener("keydown", handleKeyDown);
      document.body.style.overflow = "";
    };
  }, [open]);

  return (
    <>
      <button
        ref={triggerRef}
        onClick={() => setOpen(true)}
        aria-expanded={open}
        aria-controls="mobile-nav"
        aria-label="Open navigation menu"
        className="md:hidden p-2"
      >
        <svg
          className="h-6 w-6"
          fill="none"
          viewBox="0 0 24 24"
          stroke="currentColor"
          aria-hidden="true"
        >
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
        </svg>
      </button>

      {open && (
        <>
          {/* Backdrop */}
          <div
            className="fixed inset-0 bg-black/50 z-40"
            onClick={() => setOpen(false)}
            aria-hidden="true"
          />

          {/* Navigation Panel */}
          <div
            ref={navRef}
            id="mobile-nav"
            role="dialog"
            aria-modal="true"
            aria-label="Navigation menu"
            className="fixed inset-y-0 right-0 w-72 bg-background shadow-lg z-50 p-6"
          >
            <button
              onClick={() => {
                setOpen(false);
                triggerRef.current?.focus();
              }}
              aria-label="Close navigation menu"
              className="absolute top-4 right-4 p-2"
            >
              <svg
                className="h-6 w-6"
                fill="none"
                viewBox="0 0 24 24"
                stroke="currentColor"
                aria-hidden="true"
              >
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
              </svg>
            </button>

            <nav aria-label="Mobile navigation">
              <ul className="space-y-2 mt-8">
                <li>
                  <a href="/" className="block py-2 text-lg" onClick={() => setOpen(false)}>
                    Home
                  </a>
                </li>
                <li>
                  <a href="/services" className="block py-2 text-lg" onClick={() => setOpen(false)}>
                    Services
                  </a>
                </li>
                <li>
                  <a href="/about" className="block py-2 text-lg" onClick={() => setOpen(false)}>
                    About
                  </a>
                </li>
                <li>
                  <a href="/contact" className="block py-2 text-lg" onClick={() => setOpen(false)}>
                    Contact
                  </a>
                </li>
              </ul>
            </nav>
          </div>
        </>
      )}
    </>
  );
}

Breadcrumbs

// components/Breadcrumbs.tsx
import Link from "next/link";

interface BreadcrumbItem {
  label: string;
  href?: string;
}

export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
  return (
    <nav aria-label="Breadcrumb">
      <ol className="flex items-center gap-1 text-sm text-muted-foreground">
        {items.map((item, i) => (
          <li key={i} className="flex items-center gap-1">
            {i > 0 && (
              <span aria-hidden="true" className="mx-1">/</span>
            )}
            {item.href && i < items.length - 1 ? (
              <Link href={item.href} className="hover:text-foreground">
                {item.label}
              </Link>
            ) : (
              <span aria-current="page" className="text-foreground font-medium">
                {item.label}
              </span>
            )}
          </li>
        ))}
      </ol>
    </nav>
  );
}

Accessibility Checklist

  • Skip link to bypass repeated navigation
  • aria-label on <nav> elements to distinguish multiple navs
  • aria-current="page" on the active link
  • aria-expanded on dropdown triggers
  • Escape key closes dropdowns and mobile menus
  • Arrow keys navigate within dropdown items
  • Focus trap on modal/mobile navigation
  • Focus returns to trigger button when closing
  • Visible focus indicators on all interactive elements

Need an Accessible Website?

We build WCAG-compliant websites with proper navigation, focus management, and screen reader support. Contact us for an accessibility audit.

accessibilitynavigationARIAkeyboardReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles