) beat ARIA roles. The first rule of ARIA is 'don't use ARIA' (W3C ARIA Authoring Practices).
Focus management on route changes: when a Single-Page App router navigates, the user's focus should move to the new page's main heading or main landmark. React Router and Next.js App Router both have established patterns; the implementation must be tested with screen readers.
Keyboard parity: every interaction available via mouse must be available via keyboard. The Tab key cycles focusable elements; Enter / Space activate buttons; Arrow keys navigate composite widgets (menus, tabs, listboxes). The W3C ARIA Authoring Practices (apg.aria.org) covers each pattern.
Tools for your application
INP optimization: the 2024 Core Web Vital change
INP (Interaction to Next Paint) replaced FID (First Input Delay) as a Core Web Vital in March 2024. INP measures the latency between a user's interaction (click, tap, key press) and the next visual update — including all interactions on the page, not just the first.
The 2026 senior bar: INP < 200ms p75 on real-world devices.
What causes INP regressions:
Long JavaScript tasks on the main thread. Any task > 50ms blocks the main thread and delays the next paint.Synchronous heavy computation in event handlers. A click handler that synchronously sorts a 10,000-item list will tank INP.Cascading state updates that trigger expensive renders. A click that triggers a Context value change that re-renders 200 components will tank INP.Third-party scripts that hijack the main thread. Analytics scripts that ship 200+ KB of JS and execute on idle can block interactions arbitrarily.What you do about it:
"use client";
import { useDeferredValue, useState, useTransition } from "react";
// Pattern 1: useDeferredValue for expensive child re-renders
function SearchResults({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
// The expensive filter runs against the deferred query, not the
// current query. The input stays responsive; the list updates with
// a one-frame delay.
const deferredQuery = useDeferredValue(query);
const filtered = items.filter((item) =>
item.name.toLowerCase().includes(deferredQuery.toLowerCase()),
);
return (
<>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Search items"
/>
<ul>
{filtered.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</>
);
}
// Pattern 2: useTransition for mutations that trigger expensive re-renders
function TabSwitcher({ tabs }: { tabs: Tab[] }) {
const [activeTab, setActiveTab] = useState(tabs[0].id);
const [isPending, startTransition] = useTransition();
return (
<>
<nav role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
role="tab"
aria-selected={activeTab === tab.id}
onClick={() => {
// The expensive tab-content render runs as a transition;
// the click handler returns immediately so INP stays low.
startTransition(() => setActiveTab(tab.id));
}}
>
{tab.label}
</button>
))}
</nav>
<TabContent tabId={activeTab} isPending={isPending} />
</>
);
}
The web.dev/inp reference (web.dev/inp ) and the React 19 useDeferredValue / useTransition docs (react.dev/reference/react/useDeferredValue ) are the canonical references. Addy Osmani's INP-specific writing at addyosmani.com covers production-debugging patterns.
LCP optimization: the hero image and font story
LCP (Largest Contentful Paint) measures when the largest visible element on the page renders. The 2026 senior bar: LCP < 2.5s p75 on real-world devices. The largest element is typically the hero image or the largest text block above the fold.
The four-step optimization:
<!-- Step 1: Preload the LCP image with fetchpriority="high".
This tells the browser to fetch the image with the highest priority,
ahead of other resources. -->
<link
rel="preload"
as="image"
href="/hero.webp"
imagesrcset="/hero-480.webp 480w, /hero-1024.webp 1024w, /hero-1920.webp 1920w"
imagesizes="100vw"
fetchpriority="high"
/>
<!-- Step 2: Server-render the hero image markup.
The HTML for the hero is in the initial server response, not
rendered client-side. -->
<img
src="/hero-1024.webp"
srcset="/hero-480.webp 480w, /hero-1024.webp 1024w, /hero-1920.webp 1920w"
sizes="100vw"
alt="Product hero image"
fetchpriority="high"
loading="eager"
decoding="sync"
width="1920"
height="800"
/>
<!-- Step 3: Preload the LCP font and use font-display: swap.
If the hero text is the LCP element, the font must load fast and
fall back gracefully. -->
<link
rel="preload"
as="font"
type="font/woff2"
href="/fonts/sans-bold.woff2"
crossorigin
/>
<!-- Step 4: Defer non-critical JS.
Any third-party script that's not needed for the LCP element
should be deferred. -->
<script src="/analytics.js" defer></script>What this gets right: priority-hint preload pulls the LCP image to the front of the network queue; the explicit width / height attributes prevent layout shift (CLS); fetchpriority="high" + decoding="sync" tells the browser to decode the image without yielding; the font preload prevents font-loading-induced LCP delay.
The web.dev/lcp reference (web.dev/lcp ) is canonical. Next.js's <Image> component handles most of these optimizations automatically when you pass priority on the LCP image; the manual approach above is what other meta-frameworks require.
Accessible custom components: focus management and ARIA
Custom components must replicate the keyboard and screen-reader semantics of native HTML. The most-built custom components — modals, dropdowns, tabs, accordions — have well-established ARIA patterns documented in the W3C ARIA Authoring Practices Guide (w3.org/WAI/ARIA/apg ).
A canonical example — an accessible modal dialog with focus trap and inert background:
"use client";
import { useEffect, useRef } from "react";
type ModalProps = {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
};
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
// Open / close the native dialog element.
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen && !dialog.open) {
dialog.showModal();
} else if (!isOpen && dialog.open) {
dialog.close();
}
}, [isOpen]);
// Wire the "close" event to our onClose callback (Escape key, etc.).
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleClose = () => onClose();
dialog.addEventListener("close", handleClose);
return () => dialog.removeEventListener("close", handleClose);
}, [onClose]);
return (
<dialog
ref={dialogRef}
aria-labelledby="modal-title"
onClick={(e) => {
// Click outside the dialog content closes the modal.
if (e.target === dialogRef.current) onClose();
}}
>
<header>
<h2 id="modal-title">{title}</h2>
<button type="button" onClick={onClose} aria-label="Close dialog">
\u00d7
</button>
</header>
<div className="modal-body">{children}</div>
</dialog>
);
}
What this component gets right: native <dialog> element with showModal() — the browser handles focus trap, inert background, and Escape-key dismissal automatically; aria-labelledby links the dialog to its title for screen readers; explicit close button with aria-label; click-outside dismissal.
What this component does NOT get right: it does not manage focus restoration on close (the focus should return to the element that triggered the modal), it does not handle the case where the dialog API is unsupported (older browsers). For production-grade modal: use Radix UI Dialog or React Aria Dialog primitive — both handle every edge case.
The W3C ARIA Authoring Practices Guide for dialogs (w3.org/WAI/ARIA/apg/patterns/dialog-modal ) is canonical. Radix UI primitives (radix-ui.com ) are the production-grade implementations most modern SaaS-tier shops adopt.
Production observability: web-vitals and RUM
Senior+ frontend engineers at modern tech companies own production performance via RUM (Real User Monitoring) — Datadog RUM, Sentry Performance, Vercel Speed Insights, or a homegrown beacon. The Google web-vitals library (github.com/GoogleChrome/web-vitals ) is the canonical client-side library for emitting Core Web Vitals to your RUM endpoint.
// app/web-vitals.ts — fire Core Web Vitals to your RUM endpoint
import { onLCP, onINP, onCLS, onTTFB, onFCP, type Metric } from "web-vitals";
type RumPayload = {
metric: string;
value: number;
id: string;
rating: "good" | "needs-improvement" | "poor";
url: string;
userAgent: string;
};
function sendToRum(payload: RumPayload): void {
// Use sendBeacon if available — it survives page unload.
const url = "/api/rum";
const body = JSON.stringify(payload);
if (navigator.sendBeacon) {
navigator.sendBeacon(url, body);
} else {
fetch(url, { method: "POST", body, keepalive: true });
}
}
function reportMetric(metric: Metric): void {
sendToRum({
metric: metric.name,
value: metric.value,
id: metric.id,
rating: metric.rating,
url: window.location.href,
userAgent: navigator.userAgent,
});
}
// Subscribe to all Core Web Vitals + supporting metrics.
export function initWebVitals(): void {
onLCP(reportMetric);
onINP(reportMetric);
onCLS(reportMetric);
onTTFB(reportMetric);
onFCP(reportMetric);
}
What this code gets right: uses the official web-vitals library (handles browser quirks correctly); uses sendBeacon for the unload-survival case; subscribes to LCP, INP, CLS, plus supporting metrics; rating field comes from the library, not arbitrary thresholds.
The senior+ pattern: emit web-vitals from production, dashboard them per route in your RUM tool, alert on regressions, correlate regressions to deploys. The Chrome team's web.dev articles on Core Web Vitals (web.dev/articles/vitals ) are canonical.
Tools for your application
Frequently asked questions
How do I test accessibility in CI?
axe-core in CI is the dominant pattern. axe-core ships as @axe-core/cli, jest-axe, and playwright-axe — pick the integration that fits your test stack. Run on every PR; fail the PR if accessibility violations are introduced. Manual screen-reader testing is the complement; axe catches static violations, screen-reader testing catches workflow violations. Marcy Sutton's accessibility writing (marcysutton.com) covers the manual test patterns.
How do I handle focus on route changes in a React SPA?
Move focus to the page's main heading or main landmark on route change. In Next.js App Router and React Router v7, this is library-supported — both have established patterns for focus management on navigation. The implementation: ref on the main heading, focus() in a useEffect that runs on route change. Test with VoiceOver / NVDA — without focus management, screen-reader users get stranded on the previous page's element after navigation.
What's prefers-reduced-motion and why does it matter?
A user-OS setting that requests reduced animation. Vestibular-disorder users, motion-sickness-prone users, and many users with cognitive disabilities set this. The web requirement: respect it. The pattern: wrap animations in @media (prefers-reduced-motion: no-preference) so they only run for users who haven't requested reduction. Apple's Human Interface Guidelines and Microsoft's Fluent design system both reference this; web.dev covers the CSS pattern.
How do I handle high-contrast / forced-colors mode?
Test in Windows High Contrast mode (Settings → Accessibility → Contrast themes). The CSS @media (forced-colors: active) lets you adjust styles when the OS overrides colors with a high-contrast palette. Don't override the user's chosen palette; use system color keywords (Canvas, CanvasText, ButtonFace, ButtonText) where appropriate. MDN's forced-colors reference covers the patterns.
What's the LCP target on mobile?
< 2.5s p75 measured on real-world mobile devices. Mobile LCP is harder than desktop because the device is slower and the network is often slower. The optimization patterns are the same (priority-hint preload, server-render the hero, defer non-critical JS) but the headroom is smaller. Addy Osmani's mobile-perf writing covers the production patterns.
How important is it to fix CLS?
Required at FAANG and SaaS-tier — CLS is a Google Search ranking factor and a real UX problem (users hate elements jumping around). The 2026 target: CLS < 0.1 p75. The patterns: reserve image / iframe / embed space (aspect-ratio CSS property or width / height attributes); avoid inserting content above viewport-rendered elements (cookie banners, ads); prefer transform / opacity for animations over animating layout properties.
Should I use Radix UI / React Aria for accessibility?
Yes for production-grade work. Radix UI primitives (radix-ui.com) and React Aria (react-spectrum.adobe.com/react-aria) handle the accessibility edge cases — focus trap, focus restoration, escape-key dismissal, screen-reader announcements, keyboard navigation. Building accessible custom components from scratch is a multi-week project per component; using Radix or React Aria is the production-pragmatic path.
About the author. Blake Crosley founded ResumeGeni and writes about frontend engineering, hiring technology, and ATS optimization. More writing at blakecrosley.com .