Accessibility for developers, the 80/20
The handful of accessibility habits that fix most bugs: semantic HTML, real labels, keyboard support, focus management, and contrast.

Most accessibility advice reads like a 400-item checklist that nobody finishes. The good news: a small set of habits fixes the vast majority of real problems, and most of them are things you should be doing anyway because they make your code simpler. This is the 80/20 of accessibility for developers: the parts that matter, the parts you can ship this week, and the parts you can stop worrying about.
Why bother (the honest version)
Two reasons, no guilt trip. First, a chunk of your users navigate with a keyboard, a screen reader, voice control, or just a zoomed-in browser, and broken accessibility means they bounce. Second, the things that make a page accessible (semantic structure, real labels, predictable focus) are the same things that make it testable, maintainable, and good for SEO. You are not doing charity work. You are writing better frontend code that happens to also pass the audit.
Start with semantic HTML
The single biggest lever is using the right element. A div with an onClick is invisible to assistive tech. A button gets keyboard focus, fires on Enter and Space, announces its role, and works with voice control, all for free.
// Don't reinvent a button
<div className="btn" onClick={handleSave}>Save</div>
// Do use the element the browser already ships
<button type="button" onClick={handleSave}>Save</button>The same rule applies up and down the page. Use nav, main, header, footer, and aside for landmarks so screen reader users can jump between regions. Use one h1 per page and never skip heading levels (do not jump from h2 to h4 because it looked nicer). Use ul/ol for lists. Use a for navigation and button for actions, and do not swap them: a link that triggers a mutation and a button that navigates will both confuse users and break expectations.
If you find yourself reaching for role="button", tabIndex={0}, and a keydown handler, stop. You are rebuilding a button badly. The accessible name, role, and keyboard behavior you are about to hand-write are the default behavior of the native element.
Labels: the thing everyone skips
Every input needs a label that is programmatically associated with it, not just a placeholder. Placeholders disappear when you type, fail contrast checks, and are not reliably announced. Associate a real label with htmlFor matching the input id.
export function EmailField({ value, onChange }: {
value: string;
onChange: (v: string) => void;
}) {
return (
<div>
<label htmlFor="email">Work email</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
value={value}
onChange={(e) => onChange(e.target.value)}
aria-describedby="email-hint"
/>
<p id="email-hint">We only use this to send your receipt.</p>
</div>
);
}For icon-only buttons (the hamburger menu, the close X, the search magnifier), there is no visible text, so give them an accessible name with aria-label:
<button type="button" aria-label="Close dialog" onClick={onClose}>
<XIcon aria-hidden="true" />
</button>Note aria-hidden="true" on the icon. Decorative SVGs should be hidden from the accessibility tree so the button announces "Close dialog" and not "Close dialog, image".
Keyboard support is not optional
Tab through your own page. Right now, with your mouse hand in your lap. Can you reach every interactive element? Can you tell where focus is? Can you operate the menu, the modal, and the dropdown without a pointer? If not, a real subset of your users cannot use the feature at all.
Two failure modes dominate. The first is invisible focus: someone added outline: none in a reset and never replaced it. Do not remove focus indicators. If the default ring is ugly, restyle it, and prefer :focus-visible so the ring shows for keyboard users without flashing on every mouse click.
button:focus-visible,
a:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}The second is the keyboard trap, usually in a custom modal. When a dialog opens, focus should move into it, stay inside it while it is open (Tab from the last element wraps to the first), and return to the trigger when it closes. Escape should close it. This is enough fiddly logic that you should not write it yourself. Use the native <dialog> element or a vetted primitive like Radix UI, which handles focus trapping, Escape, and scroll locking correctly.
Color contrast and not relying on color alone
Two quick wins here. Text needs enough contrast against its background: 4.5:1 for normal text, 3:1 for large text. That trendy light-gray-on-white placeholder probably fails. Run the page through your browser devtools, which flags contrast issues inline in the inspector, or any contrast checker.
Second, never use color as the only signal. A form field that turns red on error means nothing to a colorblind user or a screen reader. Pair the color with text and a programmatic association:
<input
id="password"
type="password"
aria-invalid={hasError}
aria-describedby={hasError ? "password-error" : undefined}
/>
{hasError && (
<p id="password-error" role="alert">
Password must be at least 12 characters.
</p>
)}The role="alert" makes screen readers announce the message when it appears, and aria-describedby ties it to the input so it is read when the field gets focus.
Dynamic content and the SPA problem
Single-page apps break two assumptions screen readers rely on. When you navigate client-side, the URL changes but the page does not reload, so nothing announces the new view. After a route change, move focus to the new page's heading or a top-level container so users know something happened. Frameworks like Next.js App Router handle focus on navigation reasonably well, but verify it, especially for in-app transitions you build by hand.
The second is live updates: a toast, a "saved" confirmation, a search-results count that changes as you type. Screen reader users will not see these unless you put them in a live region. Render the container up front (empty) with aria-live="polite" and update its text content; do not mount the region and its text in the same render, or the announcement can get missed.
// Mounted once, empty. Updating `message` later triggers the announcement.
<div aria-live="polite" className="sr-only">
{message}
</div>What to actually skip
You do not need to memorize every ARIA attribute. The first rule of ARIA is to not use ARIA: a native element with the right semantics beats a div plus five aria-* props every time. You do not need to chase a perfect Lighthouse score; automated tools catch maybe a third of issues and miss things like "the focus order is nonsense." And you do not need a dedicated audit phase bolted on at the end. Build the habits into the components you write once, in your shared Button, Input, and Dialog, and you get accessibility on every page that uses them without thinking about it again.
The takeaway
Spend your first hour on the cheap, high-yield stuff: use semantic elements, label every input, tab through the page and fix what you cannot reach, keep visible focus rings, and check contrast. That handful of habits clears most real-world accessibility bugs, and it makes your codebase cleaner as a side effect. The thorough audit can come later. The 80% you can do today.
If you want a second pair of eyes on a flow that has to work for everyone, that is the kind of thing we are happy to help with.
