Back to Blog

React Accessibility: ARIA Patterns, Keyboard Navigation, Focus Management, and Screen Reader Testing

Build accessible React applications. Covers ARIA roles and attributes, keyboard navigation patterns, focus management with useRef and focus traps, skip links, screen reader testing with axe and NVDA, and accessible form patterns.

Viprasol Tech Team
April 14, 2027
14 min read

Accessibility is often treated as a compliance checkbox โ€” a final audit before launch. That framing sets teams up for expensive retrofits. Accessibility built in from the start is cheaper and results in better UX for everyone: keyboard users, screen reader users, motor-impaired users, and sighted mouse users who benefit from the same semantic structure.

This guide covers the practical ARIA patterns, keyboard navigation, focus management, and testing approaches that matter most in React applications.

The Three Rules of ARIA

Before adding ARIA attributes, understand when not to use them:

  1. Don't use ARIA if you can use a native HTML element. <button> already has role="button", keyboard focus, and activation on Enter/Space. Don't use <div role="button"> unless you have no other choice.

  2. Don't change native semantics unless you have to. Don't add role="heading" to a <p> โ€” use <h1>โ€“<h6>.

  3. All interactive ARIA elements must be keyboard accessible. If you give something a role like combobox or tree, you must also implement full keyboard support for that pattern.

Semantic HTML First

// โŒ Common mistake: divs everywhere
function BadButton({ onClick, children }: any) {
  return (
    <div onClick={onClick} style={{ cursor: "pointer" }}>
      {children}
    </div>
  );
  // Screen readers don't announce this as a button
  // Keyboard users can't Tab to it or activate it
  // No focus ring unless explicitly styled
}

// โœ… Native button: focus, keyboard, announcement for free
function GoodButton({ onClick, children }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  return (
    <button
      onClick={onClick}
      className="px-4 py-2 bg-blue-600 text-white rounded-lg
        hover:bg-blue-700 focus-visible:outline-none
        focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2
        disabled:opacity-50 disabled:cursor-not-allowed"
    >
      {children}
    </button>
  );
}

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Focus Management

Skip Link

Skip links let keyboard users jump past repeated navigation:

// components/skip-link.tsx
export function SkipLink() {
  return (
    <a
      href="#main-content"
      className={[
        "sr-only focus:not-sr-only",
        "fixed top-4 left-4 z-50",
        "bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium",
        "focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-blue-600",
      ].join(" ")}
    >
      Skip to main content
    </a>
  );
}

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <SkipLink />
        <Header />
        <main id="main-content" tabIndex={-1}>
          {children}
        </main>
      </body>
    </html>
  );
}
// tabIndex={-1} on main allows programmatic focus without adding to tab order

Focus Trap in Modals

When a modal opens, focus must be trapped inside until it closes:

// hooks/use-focus-trap.ts
import { useEffect, useRef } from "react";

const FOCUSABLE_SELECTORS = [
  "a[href]",
  "button:not([disabled])",
  "input:not([disabled])",
  "select:not([disabled])",
  "textarea:not([disabled])",
  "[tabindex]:not([tabindex='-1'])",
].join(", ");

export function useFocusTrap(isActive: boolean) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!isActive || !containerRef.current) return;

    const container = containerRef.current;
    const focusableElements = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    // Focus first element when trap activates
    firstElement?.focus();

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== "Tab") return;

      if (e.shiftKey) {
        // Shift+Tab: if focus is on first element, wrap to last
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement?.focus();
        }
      } else {
        // Tab: if focus is on last element, wrap to first
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement?.focus();
        }
      }
    };

    container.addEventListener("keydown", handleKeyDown);
    return () => container.removeEventListener("keydown", handleKeyDown);
  }, [isActive]);

  return containerRef;
}
// components/dialog.tsx โ€” accessible modal
import { useEffect, useRef } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { X } from "lucide-react";

interface DialogProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
  const containerRef = useFocusTrap(isOpen);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // Save current focus to restore on close
      previousFocusRef.current = document.activeElement as HTMLElement;
    } else {
      // Restore focus when dialog closes
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  // Close on Escape
  useEffect(() => {
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === "Escape" && isOpen) onClose();
    };
    document.addEventListener("keydown", handleEscape);
    return () => document.removeEventListener("keydown", handleEscape);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <>
      {/* Backdrop */}
      <div
        className="fixed inset-0 bg-black/50 z-40"
        onClick={onClose}
        aria-hidden="true"
      />

      {/* Dialog */}
      <div
        ref={containerRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="dialog-title"
        className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50
          bg-white rounded-xl shadow-xl p-6 w-full max-w-lg
          focus:outline-none"
      >
        <div className="flex items-start justify-between mb-4">
          <h2 id="dialog-title" className="text-lg font-semibold text-gray-900">
            {title}
          </h2>
          <button
            onClick={onClose}
            className="text-gray-400 hover:text-gray-600 rounded-md p-1
              focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
            aria-label="Close dialog"
          >
            <X className="w-5 h-5" />
          </button>
        </div>
        {children}
      </div>
    </>
  );
}

Live Regions for Dynamic Updates

// components/live-announcer.tsx
// Announce dynamic content changes to screen readers

import { useEffect, useRef } from "react";

interface LiveAnnouncerProps {
  message: string;
  politeness?: "polite" | "assertive";
}

export function LiveAnnouncer({ message, politeness = "polite" }: LiveAnnouncerProps) {
  return (
    <div
      role="status"
      aria-live={politeness}
      aria-atomic="true"
      className="sr-only"
    >
      {message}
    </div>
  );
}

// Usage in a search component:
function SearchResults({ query, results }: { query: string; results: any[] }) {
  return (
    <>
      <LiveAnnouncer
        message={
          results.length === 0
            ? `No results found for "${query}"`
            : `${results.length} result${results.length !== 1 ? "s" : ""} found for "${query}"`
        }
      />
      {/* Render results */}
    </>
  );
}

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Accessible Form Patterns

// components/form-field.tsx โ€” fully accessible form field
import { useId } from "react";

interface FormFieldProps {
  label: string;
  error?: string;
  hint?: string;
  required?: boolean;
  children: (props: {
    id: string;
    "aria-describedby"?: string;
    "aria-invalid"?: boolean;
    "aria-required"?: boolean;
  }) => React.ReactNode;
}

export function FormField({ label, error, hint, required, children }: FormFieldProps) {
  const id = useId();
  const hintId = hint ? `${id}-hint` : undefined;
  const errorId = error ? `${id}-error` : undefined;

  // aria-describedby can reference multiple IDs
  const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;

  return (
    <div className="space-y-1.5">
      <label htmlFor={id} className="block text-sm font-medium text-gray-700">
        {label}
        {required && (
          <span className="ml-1 text-red-500" aria-label="required">*</span>
        )}
      </label>

      {hint && (
        <p id={hintId} className="text-xs text-gray-500">
          {hint}
        </p>
      )}

      {children({
        id,
        "aria-describedby": describedBy,
        "aria-invalid": error ? true : undefined,
        "aria-required": required,
      })}

      {error && (
        <p id={errorId} role="alert" className="text-xs text-red-600 flex items-center gap-1">
          <span aria-hidden="true">โš </span>
          {error}
        </p>
      )}
    </div>
  );
}

// Usage:
function EmailField({ error }: { error?: string }) {
  return (
    <FormField label="Email address" error={error} required hint="We'll never share your email">
      {(ariaProps) => (
        <input
          type="email"
          {...ariaProps}
          className={`w-full rounded-lg border px-3 py-2 text-sm
            focus:outline-none focus:ring-2 focus:ring-blue-500
            ${error ? "border-red-300 bg-red-50" : "border-gray-300"}`}
        />
      )}
    </FormField>
  );
}

Keyboard Navigation: Roving tabindex

For widget collections (tabs, toolbars, radio groups), use roving tabindex โ€” only one item in the group is in the tab order at a time:

// hooks/use-roving-tabindex.ts
import { useRef, useCallback } from "react";

export function useRovingTabindex(count: number) {
  const currentIndex = useRef(0);

  const getTabIndex = useCallback(
    (index: number) => (index === currentIndex.current ? 0 : -1),
    []
  );

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent, index: number) => {
      const container = (e.currentTarget as HTMLElement).closest("[data-roving]");
      if (!container) return;

      const items = Array.from(
        container.querySelectorAll<HTMLElement>("[data-roving-item]")
      );

      let nextIndex = index;

      if (e.key === "ArrowRight" || e.key === "ArrowDown") {
        e.preventDefault();
        nextIndex = (index + 1) % count;
      } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
        e.preventDefault();
        nextIndex = (index - 1 + count) % count;
      } else if (e.key === "Home") {
        e.preventDefault();
        nextIndex = 0;
      } else if (e.key === "End") {
        e.preventDefault();
        nextIndex = count - 1;
      } else {
        return;
      }

      currentIndex.current = nextIndex;
      items[nextIndex]?.focus();
    },
    [count]
  );

  return { getTabIndex, handleKeyDown };
}

// Accessible tab list using roving tabindex
function TabList({ tabs, activeTab, onTabChange }: {
  tabs: string[];
  activeTab: number;
  onTabChange: (index: number) => void;
}) {
  const { getTabIndex, handleKeyDown } = useRovingTabindex(tabs.length);

  return (
    <div role="tablist" aria-label="Navigation tabs" data-roving>
      {tabs.map((tab, index) => (
        <button
          key={tab}
          role="tab"
          id={`tab-${index}`}
          aria-selected={index === activeTab}
          aria-controls={`panel-${index}`}
          tabIndex={getTabIndex(index)}
          onClick={() => onTabChange(index)}
          onKeyDown={(e) => handleKeyDown(e, index)}
          data-roving-item
          className={`px-4 py-2 text-sm font-medium rounded-t-lg
            focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
            ${index === activeTab
              ? "bg-white text-blue-600 border-b-2 border-blue-600"
              : "text-gray-600 hover:text-gray-900"
            }`}
        >
          {tab}
        </button>
      ))}
    </div>
  );
}

Automated Testing with axe

// __tests__/accessibility.test.tsx
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import { Dialog } from "@/components/dialog";
import { FormField } from "@/components/form-field";

expect.extend(toHaveNoViolations);

describe("Accessibility", () => {
  it("Dialog has no a11y violations", async () => {
    const { container } = render(
      <Dialog isOpen title="Test Dialog" onClose={() => {}}>
        <p>Dialog content</p>
        <button>Action</button>
      </Dialog>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it("FormField with error has no a11y violations", async () => {
    const { container } = render(
      <FormField label="Email" error="Invalid email" required>
        {(props) => <input type="email" {...props} />}
      </FormField>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

Manual Testing Checklist

TestToolHow
Keyboard navigationBrowserTab through entire page โ€” every interactive element must be reachable
Focus visibilityBrowserTab through โ€” focus ring must be visible at all times
Screen readerNVDA (Windows), VoiceOver (Mac)Navigate with arrow keys; check announcements
Color contrastaxe DevTools or LighthouseCheck all text/background combinations
Zoom to 200%BrowserNo content should overflow or become inaccessible
Reduced motionprefers-reduced-motionDisable animations for users who prefer it

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
Accessibility audit (existing app)1 dev3โ€“5 days$800โ€“1,500
Fix critical violations (Level A)1 dev1โ€“2 weeks$2,000โ€“4,000
Full WCAG 2.1 AA compliance1โ€“2 devs3โ€“6 weeks$8,000โ€“20,000
Ongoing accessibility testing in CI1 dev1โ€“2 days to set up$300โ€“600

See Also


Working With Viprasol

Accessibility built in from the start costs a fraction of what retrofitting costs โ€” and it's the right thing to build. Our team implements semantic HTML, ARIA patterns, keyboard navigation, and focus management as standard practice, with axe-based automated testing in CI to catch regressions before they ship.

What we deliver:

  • Skip links, landmark regions, and heading hierarchy audit
  • Focus trap for modals and drawers
  • Roving tabindex for tabs, toolbars, and radio groups
  • Live regions for search results, toasts, and async updates
  • Accessible form fields with aria-describedby, aria-invalid, and role="alert"
  • jest-axe integration for automated CI accessibility testing

Talk to our team about accessibility for your application โ†’

Or explore our web development services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.