Hello there! 👋 If you've ever built a UI that "looks right" but screen readers announce it as a meaningless list of "div, div, div," or your E2E tests break every time you change a class name, semantic HTML is your friend. Semantic HTML means using elements that convey meaning:<nav>, <main>, <article>, <button>, <label>; instead of generic <div> and <span> everywhere. In this post we'll see why that matters for accessibility, for automated testing, and how to do it cleanly in React with a few real component examples.
Think of it like writing a document: headings, paragraphs, and lists give structure; a wall of identical boxes does not. Browsers, assistive technologies, and test tools all rely on that structure. Semantic markup is the foundation everything else builds on.
Overview
We'll cover:
- What semantic HTML is: Meaningful elements instead of generic containers
- Why it matters for accessibility: Screen readers, keyboard navigation, and ARIA
- Why it matters for automated testing: Stable, meaningful selectors and behavior
- React examples: Practical components using semantic elements
Examples use React and JSX, but the HTML elements and concepts apply to any framework or vanilla HTML.
1. What Is Semantic HTML?
Semantic HTML is using HTML elements that describe their role and content to the browser and to assistive technologies. The tag name itself carries information.
| Semantic choice | Generic choice | What it communicates |
|---|---|---|
<header> |
<div> |
Introductory or navigational block |
<nav> |
<div> |
Navigation links |
<main> |
<div> |
Primary content of the page |
<article> |
<div> |
Self-contained composition |
<section> |
<div> |
Thematic grouping |
<aside> |
<div> |
Tangentially related content |
<footer> |
<div> |
Footer for a section or page |
<button> |
<div onClick> |
Clickable control |
<label> |
<span> |
Label for a form control |
<fieldset> |
<div> |
Group of related form fields |
Why not just use divs and spans? Because then the only way to know "this is the main content" or "this is a button" is to infer it from classes, IDs, or custom attributes. Browsers and assistive tech already understand semantic elements; you get correct behavior and exposure in the accessibility tree for free.
2. Why Semantic HTML Matters for Accessibility
Screen readers and the accessibility tree
Screen readers build an accessibility tree from the DOM. Semantic elements map to roles and landmarks (e.g. banner, navigation, main, contentinfo). Users can jump by landmark ("go to main content") or by heading level. When everything is a div, there are no landmarks and no structure, just a long list of "div" and "group."
-
<main>Identified as the main content; users can skip to it in one action. -
<nav>Exposed as "navigation" so users can find and skip nav blocks. -
<button>Announced as a button and keyboard-activatable; a<div role="button">requires extra attributes (tabindex, key handlers) and is easy to get wrong. -
<label>Associated with an input so "click label to focus field" and the correct name is announced.
Using semantic HTML reduces the need for ARIA. ARIA fixes semantics; it doesn’t replace good HTML. If you use <button>, you get focus and keyboard behavior; if you use <div role="button">, you must add tabindex and keyboard handling yourself.
Keyboard and focus
Semantic controls are focusable and keyboard-operable by default. Buttons, links, and form fields participate in tab order and respond to Enter/Space where appropriate. Divs and spans do not, so if you use them for interactive things, you have to replicate all of that and keep it in sync. Semantic HTML keeps behavior and markup aligned.
Takeaway: Semantic HTML gives you correct roles, landmarks, and default keyboard behavior. That’s why it’s the first step toward accessible UIs.
3. Why Semantic HTML Matters for Automated Testing
Stable, meaningful selectors
Tests that depend on CSS classes or IDs break when you refactor styles or change design. Tests that depend on role and semantics are more stable: "the submit button in the login form," "the main navigation," "the dialog title."
-
By role:
getByRole('button', { name: 'Submit' }), stable even if the class changes. -
By landmark:
within(getByRole('main')).getByRole('heading', { level: 1 }), tied to structure, not layout. -
By label:
getByLabelText('Email'), works because you used a real<label>(oraria-label).
Testing Library’s philosophy is to query the DOM the way users experience it: by role, label, and text. That aligns with semantic HTML: when you use <button>, <nav>, <main>, and <label>, your tests naturally use getByRole, getByLabelText, and similar. When everything is a div, you fall back to test IDs or class names, which are brittle and don’t reflect how assistive tech or users see the page.
Fewer test-only hooks
If the only way to find "the login form" is data-testid="login-form", you’re adding attributes just for tests. With semantic markup, "the form that contains the email and password fields" is findable via getByRole('form') and labels. You still can use test IDs for complex or ambiguous cases, but you need them less often.
Takeaway: Semantic HTML makes it easier to write resilient, user-centric tests (e.g. with Testing Library) and reduces reliance on fragile or test-only selectors.
4. React Examples: Semantic Components
In React, you often wrap behavior in components. It’s still important to choose the right underlying HTML element (or allow it to be configured) so the output is semantic. Here are a few patterns.
Page layout: header, main, nav, footer
export function AppLayout({ children }: { children: React.ReactNode }) {
return (
<>
<header>
<nav aria-label="Main navigation">
{/* links */}
</nav>
</header>
<main id="main-content">
{children}
</main>
<footer>
<p>© 2025 Your App</p>
</footer>
</>
);
}
-
<header>/<nav>/<main>/<footer>give landmarks for screen readers and a clear structure for tests (e.g.getByRole('main')). -
aria-label="Main navigation"distinguishes this nav if you have several. -
id="main-content"is optional and useful for "skip to main content" links.
Card as an article or section
Use <article> when the card is a self-contained unit (e.g. a blog preview); use <section> when it’s a grouped part of a larger whole.
interface CardProps {
title: string;
description: string;
href?: string;
asArticle?: boolean;
}
export function Card({ title, description, href, asArticle = true }: CardProps) {
const Wrapper = asArticle ? 'article' : 'section';
const content = (
<>
<h2>{title}</h2>
<p>{description}</p>
</>
);
return (
<Wrapper>
{href ? (
<a href={href}>{content}</a>
) : (
content
)}
</Wrapper>
);
}
-
<article>/<section>convey structure; tests can target "heading within article" or "link within section." -
Single
<h2>per card keeps heading hierarchy meaningful (one<h1>per page, then<h2>for card titles).
Dialog / modal
Use the dialog element and proper labeling so focus is trapped and the role is clear.
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!dialogRef.current) return;
if (isOpen) {
dialogRef.current.showModal();
} else {
dialogRef.current.close();
}
}, [isOpen]);
return (
<dialog
ref={dialogRef}
onCancel={onClose}
aria-labelledby="modal-title"
>
<header>
<h2 id="modal-title">{title}</h2>
<button type="button" onClick={onClose} aria-label="Close dialog">
×
</button>
</header>
<div>{children}</div>
</dialog>
);
}
-
<dialog>gives correct role and modal behavior (focus management when supported). -
aria-labelledby="modal-title"ties the dialog to its title for screen readers. -
<button type="button">for close avoids submitting forms; tests can usegetByRole('button', { name: 'Close dialog' }).
Form with fieldset and labels
Group related fields and tie labels to inputs so both accessibility and tests benefit.
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const email = form.email.value;
const password = form.password.value;
onSubmit(email, password);
};
return (
<form onSubmit={handleSubmit} aria-label="Login form">
<fieldset>
<legend>Sign in</legend>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
</div>
<button type="submit">Sign in</button>
</fieldset>
</form>
);
}
-
<form>,<fieldset>,<legend>describe the form and group;<label htmlFor="...">andidon inputs link label and control. - Tests can use
getByLabelText('Email'),getByRole('button', { name: 'Sign in' })without any test IDs. -
aria-labelon the form is optional and helps when there are multiple forms on the page.
Button vs div that looks like a button
Prefer a real button for actions; use a link only when it navigates.
// Prefer: semantic button
export function ActionButton({
children,
onClick,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button type="button" onClick={onClick} {...props}>
{children}
</button>
);
}
// Avoid: div that looks like a button (no focus, no keyboard, wrong role)
// <div role="button" tabIndex={0} onClick={...} onKeyDown={...}>...</div>
-
<button type="button">gives role, focus, and Enter/Space handling; screen readers and tests can treat it as a button. - If the action is "go to URL," use
<a href="...">instead of a button.
Quick reference: semantic choices in React
| Purpose | Prefer | Avoid |
|---|---|---|
| Page shell |
<header>, <main>, <footer>
|
Generic <div>
|
| Navigation |
<nav> (+ aria-label if multiple) |
<div> with links |
| Self-contained block |
<article> or <section>
|
<div> |
| Dialog / modal | <dialog> |
<div role="dialog"> + manual focus |
| Form grouping |
<fieldset> + <legend>
|
<div> |
| Form control label | <label htmlFor="id"> |
<span> or no label |
| Clickable action | <button type="button"> |
<div onClick> |
| Navigation to URL | <a href="..."> |
<span onClick> or <div>
|
Conclusion
Semantic HTML is the base layer for both accessibility and robust automated testing. It gives screen readers landmarks and roles, gives keyboards the right behavior, and gives tests stable, meaningful ways to find elements (by role, label, and structure) instead of brittle class or ID selectors.
Key takeaways:
-
Use elements that match meaning:
<nav>,<main>,<article>,<button>,<label>,<dialog>,<fieldset>instead of generic divs when they fit. - Accessibility: Semantic markup improves the accessibility tree and default keyboard behavior; use ARIA only when you need to supplement, not replace, semantics.
-
Testing: Query by role and label (e.g. Testing Library’s
getByRole,getByLabelText) so tests reflect how users and assistive tech see the page. - In React: Choose the right HTML element inside your components (or expose it via props) so the rendered DOM stays semantic.
A little attention to semantics up front makes accessibility and testing easier and your UI more resilient to refactors. Happy coding! 🚀
Top comments (0)