React 7,897 has no opinion about which element you render. <div> becomes a div, <button> becomes a button, and the tree is built from whatever comes out. Every role and keyboard behavior HTML gives free is a choice made in JSX, buried inside <PrimaryAction> where reviewers cannot see it.
Two spellings trip people up: attributes clashing with reserved words are renamed (className, htmlFor) and DOM properties are camelCased (tabIndex, readOnly), but aria-* and data-* keep their hyphens — aria-labelledby, never ariaLabelledBy.
Landmarks are the cheapest win: one <main>, a <nav> with an aria-label per block, a <header> and <footer>. Headings are harder, because a component rendering an <h2> cannot know how deeply its parent nested it. Pass the depth through context and let each heading pick its own tag.
const Level = createContext(1);
const Section = ({ children }) => <section>
<Level.Provider value={useContext(Level) + 1}>{children}</Level.Provider></section>;
const Heading = ({ children }) => {
const H = 'h' + Math.min(useContext(Level), 6); // capitalized: React reads it as a tag
return <H>{children}</H>;
};
const App = () => <>
<a href="#main">Skip to content</a>
<header><Heading>Web Coding Docs</Heading></header>
<nav aria-label="Primary"><a href="/guide">Guide</a></nav>
<main id="main"><Section><Heading>Routing</Heading>
<Section><Heading>Nested routes</Heading></Section></Section></main></>;The tree shows landmarks and an outline stepping 1, 2, 3 although no component wrote a number. The skip link comes first, or keyboard users retraverse the navigation on every route change.
RootWebArea "Docs"
link "Skip to content"
banner
heading "Web Coding Docs" level=1
navigation "Primary"
main
heading "Routing" level=2
heading "Nested routes" level=3Older advice says a wrapper <div> around <li> breaks semantics. Chromium 4,389 now ignores the generic wrapper and the list/listitem relationship survives — use Fragments anyway, for flex and grid parenting.