Web accessibility from the start — always apply semantic HTML, form labels, ARIA attributes, keyboard navigation, live regions, alt text, and heading hierarchy when building any UI component
93
90%
Does it follow best practices?
Impact
98%
1.24xAverage score across 5 eval scenarios
Passed
No known issues
Accessibility is not optional. Every component you build must be accessible by default. Do not wait for the user to ask for accessibility — apply these patterns proactively whenever you create or modify any UI.
Every time you create or modify a UI component, you MUST proactively include accessibility. This means:
If the user asks you to "build a contact form" — they get labels, aria-required, aria-invalid, aria-describedby for errors, role="alert" on error messages, and fieldset/legend for groups. Every time. No exceptions.
Use semantic HTML elements instead of generic <div> elements. Screen readers depend on these to navigate.
Required for every page/layout:
<header> for the top section (not <div class="header">)<nav> with aria-label for navigation sections (not <div class="nav">)<main> for primary content (not <div class="content">)<footer> for the bottom section (not <div class="footer">)<section> with heading or aria-label for distinct content areas<body>
<a href="#main-content" class="skip-link">Skip to content</a>
<header>
<nav aria-label="Main navigation">
<a href="/" aria-current="page">Home</a>
<a href="/about">About</a>
</nav>
</header>
<main id="main-content">
<h1>Page Title</h1>
<!-- Page content -->
</main>
<footer>
<!-- Footer content -->
</footer>
</body>.skip-link {
position: absolute;
left: -9999px;
top: auto;
z-index: 100;
}
.skip-link:focus {
position: fixed;
top: 0;
left: 0;
padding: 0.5rem 1rem;
background: #000;
color: #fff;
}Headings must follow a logical hierarchy. Never skip levels.
<h1> per page (the page title)<h2> for major sections<h3> for subsections within <h2><!-- CORRECT -->
<h1>Dashboard</h1>
<h2>Recent Orders</h2>
<h3>Order #1042</h3>
<!-- WRONG — skips h2 -->
<h1>Dashboard</h1>
<h3>Recent Orders</h3>Every form input MUST have a label. Every single one. No exceptions.
<!-- Visible label — always preferred -->
<label for="user-email">Email address</label>
<input id="user-email" type="email" name="email"
required aria-required="true"
autocomplete="email">
<!-- Hidden label — only when design truly cannot show one -->
<label for="search-input" class="sr-only">Search</label>
<input id="search-input" type="search" placeholder="Search...">
<!-- OR -->
<input type="search" aria-label="Search" placeholder="Search...">Mark required fields with BOTH the HTML required attribute AND aria-required="true":
<label for="name">Full name <span aria-hidden="true">*</span></label>
<input id="name" type="text" required aria-required="true">When validation fails, error messages MUST be:
aria-describedbyrole="alert"aria-invalid="true"<label for="email">Email</label>
<input id="email" type="email"
aria-describedby="email-error"
aria-invalid="true"
aria-required="true">
<span id="email-error" role="alert">Please enter a valid email address</span>In React/JSX:
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-describedby={errors.email ? "email-error" : undefined}
aria-invalid={!!errors.email}
aria-required="true"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{errors.email && (
<span id="email-error" role="alert">{errors.email}</span>
)}Radio buttons and checkboxes MUST be wrapped in <fieldset> with <legend>:
<fieldset>
<legend>Preferred contact method</legend>
<label><input type="radio" name="contact" value="email"> Email</label>
<label><input type="radio" name="contact" value="phone"> Phone</label>
</fieldset>placeholder as the only label — it disappears when typingtitle attribute as the only labelfor/id or aria-label<button> for actions (submit, toggle, delete, open modal)<a href> for navigation (going to a different page/URL)<div onclick> or <span onclick> — they are not keyboard accessible<!-- Action = button -->
<button type="button" onClick={handleAddToCart}>Add to cart</button>
<button type="submit">Submit form</button>
<!-- Navigation = link -->
<a href="/orders/123">View order #123</a>
<!-- Icon-only buttons MUST have aria-label -->
<button type="button" aria-label="Close dialog">
<svg aria-hidden="true"><!-- X icon --></svg>
</button>
<button type="button" aria-label="Delete item">
<svg aria-hidden="true"><!-- trash icon --></svg>
</button><button type="submit" disabled aria-disabled="true">
Place order
</button>Every <img> MUST have an alt attribute:
alt="Golden retriever playing in a park"alt=""aria-hidden="true"<!-- Informative -->
<img src="product.jpg" alt="Blue ceramic coffee mug, 12oz">
<!-- Decorative -->
<img src="divider.png" alt="">
<!-- Icon in button — button has the label -->
<button aria-label="Settings">
<img src="gear.svg" alt="" aria-hidden="true">
</button>:focus-visible style)/* ALWAYS provide visible focus styles */
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* NEVER do this without a replacement */
/* :focus { outline: none; } <-- BREAKS ACCESSIBILITY */If you must use a non-semantic element (rare), add keyboard support:
<div role="button" tabindex="0"
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(); }}
onClick={handleClick}>
Custom button
</div>But strongly prefer <button> which gives you all of this for free.
Modals MUST:
role="dialog" and aria-modal="true"aria-labelledby pointing to the dialog title<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm deletion</h2>
<p>Are you sure you want to delete this item?</p>
<button type="button">Cancel</button>
<button type="button">Delete</button>
<button type="button" aria-label="Close dialog">×</button>
</div>function trapFocus(modal: HTMLElement) {
const focusable = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
modal.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { closeModal(); return; }
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
first.focus();
}When content changes without a page reload, screen readers MUST be notified:
<!-- Status updates — polite (waits for pause in speech) -->
<div aria-live="polite" aria-atomic="true">
Order status: Preparing
</div>
<!-- Error messages — assertive (interrupts immediately) -->
<div role="alert">
Payment failed. Please try again.
</div>
<!-- Toast/notification -->
<div role="status" aria-live="polite">
Item added to cart
</div>
<!-- Loading state -->
<div aria-busy="true" aria-live="polite">
Loading results...
</div>| Pattern | Element | Use |
|---|---|---|
| Status update | aria-live="polite" | Non-urgent updates |
| Error message | role="alert" | Urgent errors, form validation |
| Toast | role="status" | Success messages, notifications |
| Loading | aria-busy="true" | While content is loading |
Tables MUST have proper headers and captions:
<table>
<caption>Monthly sales report</caption>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col">Units sold</th>
<th scope="col">Revenue</th>
</tr>
</thead>
<tbody>
<tr>
<td>Widget A</td>
<td>150</td>
<td>$4,500</td>
</tr>
</tbody>
</table>/* Good contrast */
.error-text {
color: #d32f2f; /* Red text */
/* Also include a visual indicator beyond just color */
}
.error-text::before {
content: "⚠ ";
}Every page/layout:
<header>, <nav>, <main>, <footer> (not div soup)<h1>, logical heading hierarchy:focus-visible stylesEvery form:
<label> with for/id (or htmlFor in React)aria-required="true"role="alert" and are linked via aria-describedbyaria-invalid="true"<fieldset> + <legend>Every interactive element:
<button>, <a>, not <div onclick>)aria-label, or aria-labelledby)aria-labelEvery image:
alt attribute (descriptive or empty for decorative)aria-hidden="true"Every modal:
role="dialog" and aria-modal="true"aria-labelledby pointing to titleEvery dynamic update:
aria-live="polite" or role="status"role="alert"aria-busy="true"