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-labelon<nav>elements to distinguish multiple navsaria-current="page"on the active linkaria-expandedon 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.