Stop treating a11y as a phase
We once scheduled a two-week “accessibility sprint” after launch. Half the issues were buttons that were divs, forms without labels, and focus traps in modals. Fixing them late meant redesigning components customers already learned.
WCAG 2.2 AA is the bar most clients ask for. You do not need to memorize every success criterion. You need a repeatable check that catches the failures users hit every day: cannot tab, cannot read, cannot submit, cannot dismiss.
Legal risk is real in some markets, but I pitch a11y as product quality: more users can complete checkout. That framing gets budget.
Keyboard: if you cannot tab it, it is broken
I unplug the mouse and walk the happy path. Tab order should follow visual order. Interactive elements need a visible focus ring—do not remove outline without a replacement.
Custom controls must be reachable and operable with Enter/Space. Escape should close dialogs. Focus must move into a modal on open and return to the trigger on close. I have shipped a date picker that trapped focus forever; support tickets wrote themselves.
- All actions reachable via keyboard
- Visible :focus-visible styles on controls
- Modals: focus trap, Escape, restore focus
- No keyboard traps in carousels or menus
- Skip link to main content on content-heavy pages
Names, roles, and the accessibility tree
Screen readers announce accessible names. An icon-only button with no aria-label is silence. Prefer visible text. When the design is icon-only, add aria-label that describes the action, not the icon shape.
Use native elements first: button, a, input, select. Role soup on divs is harder to get right. If you must build a custom widget, match the ARIA authoring practices for that pattern and test with VoiceOver or NVDA.
<!-- Bad -->
<div class="icon-btn" onclick="deleteItem()">🗑</div>
<!-- Better -->
<button type="button" class="icon-btn" aria-label="Delete item" onclick="deleteItem()">
<span aria-hidden="true">🗑</span>
</button>
<!-- Links go places; buttons do things -->
<a href="/settings">Settings</a>
<button type="button" onclick="save()">Save</button>
Forms that fail for the right reasons
Every input needs a label associated with for/id or wrapping label. Placeholder is not a label. Error text must be tied to the field with aria-describedby and announced when validation runs.
Required fields need both visual indication and aria-required or the required attribute. Do not rely on color alone for errors.
Autocomplete attributes on auth and checkout fields help password managers and reduce errors. They are part of usable accessibility.
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
aria-invalid="true"
aria-describedby="email-error"
required
/>
<p id="email-error" class="error" role="alert">Enter a valid email address.</p>
Color, contrast, and motion
Body text should meet contrast ratios against its background. I use the browser DevTools contrast checker on real components, not just the design file, because CSS overlays change effective contrast.
Never convey state with color alone—add text or icons. Respect prefers-reduced-motion: disable non-essential animation when the user asks for it. Autoplaying video with sound is an automatic fail in my review.
- Text contrast roughly 4.5:1 for normal text (AA)
- UI component contrast for icons and borders where required
- Status not by color alone
- prefers-reduced-motion media query honored
- Images have meaningful alt; decorative images alt=""
Images, media, and page structure
Alt text describes purpose. Decorative images get empty alt so readers skip them. Charts need a text summary or data table nearby—alt alone rarely carries a complex chart.
One h1 per page, heading levels in order. Landmarks (header, nav, main, footer) help skip navigation. I catch heading skips in PR review more often than exotic ARIA bugs.
My PR checklist (copy this)
I paste a short version into the PR template. If any box fails, the UI is not done.
- Keyboard-only pass of the changed flow
- Focus visible; modals manage focus
- Buttons/links have accessible names
- Form labels and error announcements present
- Contrast checked on new text and controls
- Automated axe or eslint-plugin-jsx-a11y clean on touched files
Automated tools and their blind spots
axe-core, Lighthouse, and eslint-plugin-jsx-a11y catch missing alt, contrast failures, and duplicate labels. They do not catch “the checkout flow makes no sense when tabbing” or “the toast disappears before a screen reader finishes.” Automation is a gate, not a certificate.
I run axe in CI on storybook or critical routes. Failures block merge. I still manually keyboard-test flows that change focus management. That split keeps noise low and coverage real.
When design hands over a component, I ask for focus states and error text in the spec. Retrofitting focus rings after visual QA freezes the design is how a11y work gets cut.
Dynamic content and live regions
SPAs update the page without full reloads. Screen reader users need announcements for important changes. aria-live regions for form success, cart updates, and filter result counts prevent silent UI changes.
Do not mark the entire page as live. That creates announcement storms. Prefer polite live regions for status and assertive only for critical errors. Clear the message node carefully so repeated identical messages still fire when needed.
<div aria-live="polite" aria-atomic="true" class="sr-only" id="status"></div>
<script>
function announce(msg) {
const el = document.getElementById('status');
el.textContent = '';
requestAnimationFrame(() => { el.textContent = msg; });
}
announce('3 products matched your filters');
</script>
Mobile touch targets and zoom
WCAG 2.2 pays more attention to target size. Tiny icon hit areas frustrate motor impairments and everyone on a bumpy train. I aim for about 24×24 CSS pixels minimum, larger when the UI allows.
Do not disable pinch zoom with maximum-scale=1. Users with low vision rely on zoom. If your layout breaks when zoomed, fix the layout.
Touch and keyboard both matter on hybrid devices. A control that works with a mouse hover menu but not with tap or keyboard is incomplete.
Content language and readable text
Set lang on the html element. Mark language changes inline when quoting another language so screen readers switch voices correctly.
Line length and spacing affect cognitive load. Extremely long lines of body text are harder for many readers. I keep article measure around 60–75 characters where the design allows.
Avoid justified text with awkward gaps on the web. Left-aligned body text remains the safest default for mixed languages and zoom levels.
Shipping culture beats heroics
The checklist only works if someone owns it. On my teams that is every frontend engineer for their PR, plus a periodic audit of top funnels with a screen reader.
Designers and engineers should share the same definition of done: keyboard works, name is announced, errors are clear, contrast passes. When that definition is in the ticket template, debates shrink.
You will not hit perfection on day one. You will stop shipping the same five bugs repeatedly. That is the win. Start with the checklist above on the next UI PR and expand coverage as the component library matures.
Accessibility is quality engineering with a human face. Treat it like performance budgets: measurable, enforceable, and part of normal delivery—not a special occasion.
Key takeaways
- Bake keyboard, names, forms, and contrast into every UI PR—not a late sprint.
- Prefer native HTML controls; add ARIA only when you must extend behavior.
- Icon-only controls need aria-label; placeholders are not labels.
- Tie error messages to inputs with aria-describedby and announce them.
- Check contrast in the rendered page and honor prefers-reduced-motion.
- Use a short PR checklist plus an automated scanner for regressions.
About the author
Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.