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 findings from the security scan
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"