Every team I've seen handle accessibility the same way handles it badly. They build the product. They ship it. Months later, someone runs an audit. The audit finds 47 issues. A developer gets assigned to fix them all. They spend three weeks retrofitting ARIA attributes onto div soup, breaking existing behavior, and wondering why the screen reader is announcing "button button clickable" on every interactive element.
The fix takes 10x longer than doing it right would have. And the result is worse.
Here's how it usually goes:
Sprint 1-20: Ship features. No one mentions accessibility.
Sprint 21: Legal/compliance says we need an audit.
Sprint 22: Audit comes back with 47 WCAG violations.
Sprint 23-26: One developer tries to fix everything.
Sprint 27: Half the fixes broke something else.
Sprint 28: Team decides "accessibility is hard."
The mistake is not that the team didn't care about accessibility. It's that they treated it as a separate task instead of a default.
Accessibility isn't a feature you bolt on. It's a quality that emerges from using the platform correctly. Most of the work is already done for you by the browser. You just have to stop overriding it.
The rest of this post is the practical stuff. What to do, what to stop doing, and what it looks like in code.
The single highest-impact accessibility decision you can make is using the
right HTML element. Not adding ARIA. Not installing a library. Just using
<button> instead of <div>.
This is the most common accessibility failure on the web:
<!-- Don't do this -->
<div class="btn" onclick="handleClick()">Save</div>
This gives you a clickable rectangle. That's it. Here's what it's missing:
Now the "fix" is three lines:
<!-- Do this instead -->
<button type="button" onclick="handleClick()">Save</button>
Here's what you get for free from that single element change:
| Feature | <div onclick> | <button> |
| ----------------------- | --------------- | ---------- | | Focusable via
Tab | No | Yes | | Enter/Space activates | No | Yes | | Screen reader role
| None | "button" | | :focus-visible works | No | Yes | |
disabled attribute | No | Yes | | Form submission support |
No | Yes | | Cursor changes on hover | No | Yes |
Seven behaviors from one element. Zero JavaScript needed for any of them.
When the browser parses your HTML, it builds an accessibility tree parallel to the DOM tree. Screen readers and other assistive tech read from this tree, not the DOM. Here's what each approach produces:
<div onclick="handleClick()">Save</div>
Accessibility tree:
┌─────────────────────┐
│ role: generic │
│ name: "" │
│ focusable: false │
│ text content: "Save" │
└─────────────────────┘
Screen reader says: "Save"
(No indication this is interactive)
<button type="button" onclick="handleClick()">Save</button>
Accessibility tree:
┌─────────────────────┐
│ role: button │
│ name: "Save" │
│ focusable: true │
│ states: enabled │
└─────────────────────┘
Screen reader says: "Save, button"
(User knows they can press it)
Same pattern applies to page layout. Compare:
<!-- Don't do this -->
<div class="header">...</div>
<div class="nav">...</div>
<div class="content">...</div>
<div class="sidebar">...</div>
<div class="footer">...</div>
<!-- Do this instead -->
<header>...</header>
<nav>...</nav>
<main>...</main>
<aside>...</aside>
<footer>...</footer>
The second version creates landmark regions in the accessibility tree. Screen reader users can jump between landmarks with a single keystroke. The div version gives them nothing. They have to arrow through every element on the page to find the main content.
Landmark navigation (semantic):
┌──────────┐ ┌──────┐ ┌──────┐ ┌───────┐ ┌────────┐
│ banner │→ │ nav │→ │ main │→ │ aside │→ │ footer │
└──────────┘ └──────┘ └──────┘ └───────┘ └────────┘
Press "d" to jump between landmarks in VoiceOver
Landmark navigation (div soup):
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ generic │→ │ generic │→ │ generic │→ │ generic │→ ...
└─────────┘ └─────────┘ └─────────┘ └─────────┘
No landmarks. No shortcuts. Arrow through everything.
<!-- Don't do this -->
<span class="link" onclick="navigate('/about')"> About us </span>
<!-- Do this instead -->
<a href="/about">About us</a>
The <a> element gives you: right-click to open in new
tab, middle-click, Ctrl/Cmd+click, screen reader link navigation,
crawlable by search engines, works with JavaScript disabled, shows the URL
in the status bar on hover. The span gives you none of those.
<!-- Don't do this -->
<div class="features">
<div>Fast builds</div>
<div>Hot reload</div>
<div>TypeScript support</div>
</div>
<!-- Do this instead -->
<ul>
<li>Fast builds</li>
<li>Hot reload</li>
<li>TypeScript support</li>
</ul>
A screen reader announces "list, 3 items" before reading the contents. The user immediately knows how many items there are and can skip the list if they want. The div version reads as three unrelated paragraphs.
If you've ever removed outlines because they "look ugly" when clicking buttons, you were solving the wrong problem:
/* Don't do this */
button:focus {
outline: none;
}
/* This removes outlines for keyboard users too */
The :focus-visible pseudo-class solves this. It only applies
when the browser determines the user is navigating with a keyboard:
/* Do this instead */
button:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Optionally suppress the default for mouse clicks */
button:focus:not(:focus-visible) {
outline: none;
}
Now mouse users see no outline (clean). Keyboard users see a clear outline (accessible). Both are happy.
A global approach using Tailwind CSS v4:
/* In your global styles */
:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
This one trips people up. Tab order follows the DOM, not the visual
layout. When you rearrange elements with CSS (flexbox order,
grid placement, absolute positioning), the visual order changes but the
tab order doesn't.
<!-- Visual order: 3, 1, 2. Tab order: 1, 2, 3. Confusing. -->
<div style="display: flex;">
<button style="order: 2;">First in DOM</button>
<button style="order: 3;">Second in DOM</button>
<button style="order: 1;">Third in DOM</button>
</div>
The fix is to make the DOM order match the visual order. If you need a different visual arrangement, restructure the HTML instead of using CSS ordering.
<!-- DOM order matches visual order -->
<div style="display: flex;">
<button>Third visually and in DOM</button>
<button>First visually and in DOM</button>
<button>Second visually and in DOM</button>
</div>
Screen reader and keyboard users shouldn't have to tab through your entire navigation on every page. A skip link lets them jump straight to the main content:
<body>
<a href="#main-content" class="skip-link"> Skip to main content </a>
<nav>
<!-- 15 navigation links -->
</nav>
<main id="main-content">
<!-- Page content -->
</main>
</body>
.skip-link {
position: absolute;
top: -100%;
left: 0;
padding: 0.5rem 1rem;
background: #000;
color: #fff;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
Invisible by default. Appears when focused with Tab. First thing a keyboard user hits on any page. Costs almost nothing to implement.
When a modal is open, Tab should cycle within the modal, not escape to the
content behind it. The inert attribute handles this natively:
<div id="app" inert>
<!-- Main page content. Inert = not focusable, not interactive. -->
</div>
<dialog open>
<h2>Confirm delete</h2>
<p>This action cannot be undone.</p>
<button>Cancel</button>
<button>Delete</button>
</dialog>
When the modal opens, set inert on everything behind it. When
it closes, remove it. No focus trap library needed. The browser handles
it.
In React:
function Modal({
isOpen,
onClose,
children,
}: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
const appRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const app = document.getElementById("app");
if (!app) return;
if (isOpen) {
app.setAttribute("inert", "");
} else {
app.removeAttribute("inert");
}
return () => app.removeAttribute("inert");
}, [isOpen]);
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true" aria-label="Confirmation">
{children}
<button onClick={onClose}>Close</button>
</div>
);
}
Or better yet, use the native <dialog> element which
handles focus trapping, Escape to close, and backdrop clicks
automatically:
function Modal({
isOpen,
onClose,
children,
}: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
dialog.showModal();
} else {
dialog.close();
}
}, [isOpen]);
return (
<dialog ref={dialogRef} onClose={onClose}>
{children}
</dialog>
);
}
WCAG defines two levels of contrast requirements:
| Level | Normal text | Large text (18px+ bold or 24px+) | | ----- | ----------- | -------------------------------- | | AA | 4.5:1 | 3:1 | | AAA | 7:1 | 4.5:1 |
Most teams check contrast at the end. They pick colors that look good, build the whole UI, then run a contrast checker and find out half their grays fail. Now they either change the design system or add exceptions everywhere.
The better approach is to build contrast guarantees into your tokens.
Start with your background color and define text colors that are guaranteed to meet AA contrast against it:
:root {
/* Backgrounds */
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #e9ecef;
/* Text - each guaranteed AA contrast against its intended background */
--text-primary: #1a1a1a; /* 17.4:1 on white - passes AAA */
--text-secondary: #525252; /* 7.4:1 on white - passes AAA */
--text-tertiary: #737373; /* 4.6:1 on white - passes AA */
--text-disabled: #a3a3a3; /* 2.6:1 on white - decorative only */
/* Interactive */
--color-link: #1d4ed8; /* 8.5:1 on white - passes AAA */
--color-focus-ring: #2563eb; /* 7.2:1 on white - passes AAA */
/* Status */
--color-error: #b91c1c; /* 7.8:1 on white */
--color-success: #166534; /* 8.2:1 on white */
}
The key: every text/foreground token is defined as a pair with its background token. You don't pick a "nice gray." You pick a gray that hits 4.5:1 against the background it will sit on.
Add comments or a lookup table so developers know which combinations are safe:
/*
* SAFE PAIRINGS (AA compliant):
*
* --text-primary on --bg-primary ✓ 17.4:1
* --text-primary on --bg-secondary ✓ 16.1:1
* --text-primary on --bg-tertiary ✓ 13.2:1
* --text-secondary on --bg-primary ✓ 7.4:1
* --text-secondary on --bg-secondary ✓ 6.8:1
* --text-tertiary on --bg-primary ✓ 4.6:1
* --text-tertiary on --bg-secondary ✗ 4.2:1 <-- FAILS AA
*
* UNSAFE PAIRINGS (do not use for text):
* --text-disabled on anything <-- decorative/icon use only
*/
Now when someone picks --text-tertiary on
--bg-secondary, they see the warning before the code ships.
No audit needed.
Don't wait for an audit. Check while building:
npx wcag-contrast for spot
checks
axe-core in your test suite
catches contrast failures in CI
// In a Playwright or Cypress test
import { checkA11y } from "axe-playwright";
test("homepage meets contrast requirements", async ({ page }) => {
await page.goto("/");
await checkA11y(page, undefined, {
rules: {
"color-contrast": { enabled: true },
},
});
});
When content changes without a page navigation, sighted users see the
change. Screen reader users don't unless you tell them. That's what
aria-live does. It creates a live region that announces
content changes.
<!-- Polite: waits for the screen reader to finish its current announcement -->
<div aria-live="polite">
<!-- Content changes here get announced after current speech -->
</div>
<!-- Assertive: interrupts whatever the screen reader is saying -->
<div aria-live="assertive">
<!-- Content changes here get announced immediately -->
</div>
Use polite for almost everything. Use
assertive only for urgent errors that need immediate
attention.
function ToastContainer() {
const [toasts, setToasts] = useState<string[]>([]);
return (
<div
aria-live="polite"
aria-atomic="true"
role="status"
className="fixed bottom-4 right-4 space-y-2"
>
{toasts.map((message, i) => (
<div key={i} className="rounded bg-gray-800 px-4 py-2 text-white">
{message}
</div>
))}
</div>
);
}
The aria-live="polite" region exists in the DOM from the
start. When a toast is added, the screen reader announces the new content.
If you create the live region dynamically at the same time as the content,
some screen readers miss the announcement.
function EmailField() {
const [error, setError] = useState<string | null>(null);
function validate(value: string) {
if (!value.includes("@")) {
setError("Please enter a valid email address");
} else {
setError(null);
}
}
return (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={error ? "true" : undefined}
aria-describedby={error ? "email-error" : undefined}
onBlur={(e) => validate(e.target.value)}
/>
<div id="email-error" aria-live="polite" role="alert">
{error}
</div>
</div>
);
}
The error container is always in the DOM. When the error text appears, the
live region announces it. The aria-describedby links the
error to the input so screen readers announce the error when the input is
focused.
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
return (
<div>
<div aria-live="polite" role="status">
{loading ? "Loading results..." : `${results.length} results found`}
</div>
<ul>
{results.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
</div>
);
}
Don't put aria-live on a container that changes frequently.
If you have a list that adds items every second, the screen reader will
try to announce every change and become unusable.
<!-- Don't do this - announces every single update -->
<div aria-live="polite">
<ul>
<li>Message 1</li>
<li>Message 2</li>
<!-- New messages added constantly -->
</ul>
</div>
<!-- Do this instead - announce a summary -->
<div aria-live="polite" role="status">3 new messages</div>
<ul>
<li>Message 1</li>
<li>Message 2</li>
<li>Message 3</li>
</ul>
Keep live regions small and specific. Announce summaries, not entire content blocks.
Some users get motion sickness from animations. The
prefers-reduced-motion media query lets you respect their
OS-level preference.
/* Default: animations on */
.fade-in {
animation: fadeIn 0.3s ease-in;
}
.slide-up {
animation: slideUp 0.4s ease-out;
}
/* Reduced motion: disable decorative animations */
@media (prefers-reduced-motion: reduce) {
.fade-in,
.slide-up {
animation: none;
}
}
A better approach is to flip the default. Start with no animations and add them only when the user hasn't requested reduced motion:
/* No animation by default */
.card {
opacity: 1;
}
/* Only animate when motion is OK */
@media (prefers-reduced-motion: no-preference) {
.card {
animation: fadeIn 0.3s ease-in;
}
}
This is safer. If the media query isn't supported, the user gets no animation instead of potentially harmful animation.
Not all motion should be removed. Functional transitions that help users understand state changes should stay. Decorative animations that exist for visual flair should go.
| Keep (reduce, don't remove) | Remove entirely | | ----------------------------- | --------------------------- | | Page transitions (cross-fade) | Parallax scrolling | | Button press feedback | Background animations | | Accordion open/close | Auto-playing carousels | | Tooltip fade-in | Bouncing scroll cues | | Progress indicators | Decorative particle effects |
For things you keep, reduce the duration and distance:
@media (prefers-reduced-motion: reduce) {
/* Don't remove the accordion transition, just make it instant */
.accordion-content {
transition-duration: 0.01s;
}
}
/* Utility: apply to any element that has decorative animation */
@media (prefers-reduced-motion: reduce) {
.motion-safe {
animation: none !important;
transition: none !important;
}
}
Or with Tailwind CSS v4:
<div class="animate-bounce motion-reduce:animate-none">Scroll down</div>
<div class="transition-transform duration-300 motion-reduce:duration-[0.01s]">
Accordion content
</div>
function usePrefersReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(false);
useEffect(() => {
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
setPrefersReduced(mql.matches);
function handleChange(e: MediaQueryListEvent) {
setPrefersReduced(e.matches);
}
mql.addEventListener("change", handleChange);
return () => mql.removeEventListener("change", handleChange);
}, []);
return prefersReduced;
}
// Usage
function AnimatedComponent() {
const prefersReduced = usePrefersReducedMotion();
return (
<div
style={{
transition: prefersReduced ? "none" : "transform 0.3s ease",
}}
>
Content
</div>
);
}
Forms are where accessibility failures cause real harm. A sighted user can visually associate a label with an input. A screen reader user cannot unless you explicitly connect them.
<!-- Don't do this -->
<div class="form-group">
<span class="label">Email</span>
<input type="email" />
</div>
<!-- Do this instead -->
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" />
</div>
In React, use htmlFor:
<label htmlFor="email">Email</label>
<input type="email" id="email" />
When a label is associated with an input, clicking the label focuses the
input. Screen readers announce the label when the input is focused. Both
are free. You just need the for/id connection.
function PasswordField() {
const [error, setError] = useState<string | null>(null);
return (
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
aria-invalid={error ? "true" : undefined}
aria-describedby="password-hint password-error"
aria-required="true"
/>
<p id="password-hint">Must be at least 8 characters</p>
<p id="password-error" role="alert" aria-live="polite">
{error}
</p>
</div>
);
}
aria-describedby accepts multiple IDs. The screen reader
announces both the hint and the error when the input is focused:
"Password, edit text, required. Must be at least 8 characters. Password is
too short."
Radio buttons and checkbox groups need context. "Yes" and "No" mean nothing without the question:
<!-- Don't do this -->
<p>Do you agree to the terms?</p>
<label><input type="radio" name="terms" /> Yes</label>
<label><input type="radio" name="terms" /> No</label>
<!-- Do this instead -->
<fieldset>
<legend>Do you agree to the terms?</legend>
<label><input type="radio" name="terms" /> Yes</label>
<label><input type="radio" name="terms" /> No</label>
</fieldset>
With fieldset and legend, a screen reader
announces "Do you agree to the terms? group" when entering the group, then
"Yes, radio button" for each option. Without it, it just says "Yes, radio
button" with no context.
Putting it all together:
function SignupForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
return (
<form noValidate onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Full name</label>
<input
id="name"
type="text"
aria-required="true"
aria-invalid={errors.name ? "true" : undefined}
aria-describedby={errors.name ? "name-error" : undefined}
/>
<p id="name-error" aria-live="polite" role="alert">
{errors.name}
</p>
</div>
<div>
<label htmlFor="signup-email">Email</label>
<input
id="signup-email"
type="email"
aria-required="true"
aria-invalid={errors.email ? "true" : undefined}
aria-describedby={errors.email ? "email-error" : undefined}
/>
<p id="email-error" aria-live="polite" role="alert">
{errors.email}
</p>
</div>
<fieldset>
<legend>Notification preferences</legend>
<label>
<input type="checkbox" name="notifications" value="email" />
Email notifications
</label>
<label>
<input type="checkbox" name="notifications" value="sms" />
SMS notifications
</label>
</fieldset>
<button type="submit">Create account</button>
</form>
);
}
Every input has a label. Every error is linked to its input. Groups have legends. Required fields are marked. Invalid fields are flagged. All of it is native HTML with a few ARIA attributes. No library needed.
Here's the 80/20 list. These items cost almost nothing during development and prevent the majority of audit findings.
| Default | Cost | Impact | | ----------------------------------------- |
---- | ------ | | Use <button> for clickable elements |
Zero | High | | Use semantic elements (header, nav, main) | Zero | High |
| Use <a> for navigation | Zero | High | | Associate
labels with inputs (for/id) | Low | High | | Use
<ul>/<ol> for lists | Zero | Medium | | Add
alt text to images | Low | High | | Use
<fieldset>/<legend> for groups | Low | Medium | |
Add :focus-visible styles | Low | High | | Match DOM order to
visual order | Low | Medium | | Add skip navigation link | Low | Medium |
| Build contrast into design tokens | Low | High | | Use
aria-live for dynamic updates | Low | High | | Add
prefers-reduced-motion support | Low | Medium | | Use
aria-describedby for error messages | Low | High | | Use
native <dialog> for modals | Low | High | | Add
lang attribute to <html> | Zero | Medium |
Everything in this list is "do it once, benefit forever." None of it requires an accessibility expert. None of it requires special tooling. It's just using the platform correctly.
The difference between "accessible by default" and "accessible by audit" isn't about caring more or trying harder. It's about when you pay the cost.
An audit finds problems after they're baked into the codebase, the design system, and the team's muscle memory. Fixing them means changing habits, refactoring components, and retesting everything. The cost compounds the longer you wait.
Defaults cost almost nothing because you pay as you go. Using
<button> instead of <div> doesn't take
longer. Adding htmlFor to a label doesn't take longer.
Defining color tokens with contrast ratios doesn't take longer. You're
doing the same amount of work either way. One approach produces accessible
output. The other produces an audit finding.
Build the defaults into your component library. Build them into your linter rules. Build them into your code review checklist. Make the accessible path the path of least resistance, and accessibility stops being a separate workstream.
The best accessibility work is the kind nobody notices because it was never broken in the first place.