
Web Accessibility: Can Someone Use My Service with Just a Keyboard?
I tried navigating my own service with just a keyboard and tab focus jumped everywhere. What I learned fixing accessibility issues in practice.

I tried navigating my own service with just a keyboard and tab focus jumped everywhere. What I learned fixing accessibility issues in practice.
Tired of naming classes? Writing CSS directly inside HTML sounds ugly, but it became the world standard. Why?

Class is there, but style is missing? Debugging Tailwind CSS like a detective.

For visually impaired, for keyboard users, and for your future self. Small `alt` tag makes a big difference.

Clicked a button, but the parent DIV triggered too? Events bubble up like water. Understand Propagation and Delegation.

I set the mouse aside. Just Tab, Enter, Space, and arrow keys. Should be straightforward.
First Tab press: focus went somewhere invisible. I couldn't see any focus indicator on screen. Second Tab: a brief outline flickered on the header logo then vanished. Third Tab: focus jumped to a link in the footer, skipping the navigation entirely.
I opened a modal and kept pressing Tab trying to find the close button. Focus escaped the modal and moved to content behind it. I was navigating the background page while the modal was open. I pressed Escape. Nothing happened.
The form fields had no labels, only placeholders. The moment I started typing, the placeholder disappeared. I couldn't remember what that field was asking for.
I gave up in under ten minutes. My service was nearly unusable without a mouse.
Screen reader users. People with motor impairments who rely on keyboards. Users who simply don't have a mouse. I had effectively locked them out.
I knew accessibility mattered. The concept wasn't unfamiliar. But it wasn't showing up in my work.
The habit of building everything with div. Clickable elements made with <div onClick={...}> because styling felt easier. That div gets mouse clicks but receives no keyboard focus. Enter and Space don't activate it. For a keyboard user, it's an invisible button.
<img> tags with missing or empty alt attributes. Screen readers announce these as "image" or the raw filename. Hearing "button-icon-32.png" as a description had never been a concern.
Color contrast: light gray text that felt elegant in a design sense could be completely invisible to someone with low vision.
A useful analogy: a building with only stairs. The designer uses the stairs daily and sees no problem. But wheelchair users, parents with strollers, and workers with heavy carts can't enter. Add a ramp and everyone benefits. Accessibility works the same way. It's not a concession for edge cases—it's better design for everyone.
Eighty percent of accessibility improvements come from semantic HTML alone.
A div is a meaningless box. Browsers and screen readers see a div and have nothing to go on. Tags like <button>, <nav>, <main>, <header>, and <article> carry built-in meaning. The browser sees them and understands: "This is a navigation area. This is something clickable."
| Bad (div soup) | Good (semantic HTML) |
|---|---|
<div class="nav"> | <nav aria-label="Main navigation"> |
<div class="btn" onClick={...}> | <button type="button"> |
<div class="heading">Title</div> | <h2>Title</h2> |
<div class="list"> + <div class="item"> | <ul> + <li> |
<div class="main-content"> | <main> |
<div class="footer"> | <footer> |
Using <button> gives you keyboard focus, Enter/Space activation, and screen reader announcement—all for free. Replicating that with <div> means manually adding tabindex="0", role="button", and a onKeyDown handler. That's extra code with more ways to get it wrong.
Proper <h1> through <h6> hierarchy lets screen reader users jump between headings with a keyboard shortcut, using the page like a table of contents. If the design requires different font sizes, keep the correct heading level and adjust visually with CSS.
This is speaking the browser's language. When markup uses the vocabulary that browsers and assistive technologies understand, a lot of behavior becomes automatic without additional ARIA.
The first rule of ARIA is:
Don't use ARIA. If a native HTML element or attribute provides the semantics and behavior you need, use it instead.
That sounds odd. ARIA is an accessibility specification—why avoid it?
Because ARIA only changes what a screen reader announces. It doesn't create behavior. Adding role="button" to a div makes a screen reader say "button," but Enter and Space still won't work. Focus management still has to be implemented manually. A plain <button> does all of that automatically. ARIA is a last resort for custom widgets that native HTML can't express.
Where ARIA genuinely helps:
role="combobox", aria-expanded, aria-autocompleterole="tablist", role="tab", role="tabpanel", aria-selectedaria-live="polite" or aria-live="assertive"aria-busy="true"aria-current="page"Redundant ARIA is a common mistake:
// Wrong: nav already implies the navigation landmark
<nav role="navigation">
// Right: nav is the landmark; add a label if there are multiple navs
<nav aria-label="Main navigation">
Incorrect ARIA actively degrades accessibility. Wrong roles cause screen readers to misrepresent elements, which is worse than no ARIA at all. Use native HTML first. Reach for ARIA only when native options are exhausted.
When screen reader users and keyboard-only users arrive at a page, they have to navigate through the header and main navigation before reaching the content—every single time. A skip link solves this: it sits at the very top of the page, invisible until Tab is pressed, and lets users jump straight to the main content.
// SkipNav.tsx
export function SkipNav() {
return (
<a
href="#main-content"
className="
sr-only
focus:not-sr-only
focus:fixed
focus:top-4
focus:left-4
focus:z-50
focus:px-4
focus:py-2
focus:bg-blue-600
focus:text-white
focus:rounded
"
>
Skip to main content
</a>
);
}
// In layout
<SkipNav />
<Header />
<main id="main-content" tabIndex={-1}>
{children}
</main>
sr-only hides the link visually while keeping it accessible. focus:not-sr-only makes it appear when focused. tabIndex={-1} on main allows focus to land there when the skip link is activated.
When a modal opens, Tab should cycle through focusable elements inside it—not leak into the background. This is a focus trap.
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, children }: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
previousFocusRef.current = document.activeElement as HTMLElement;
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusableElements?.[0]?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key === 'Tab' && focusableElements) {
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
if (!isOpen && previousFocusRef.current) {
previousFocusRef.current.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
ref={modalRef}
>
<h2 id="modal-title">Modal Title</h2>
{children}
<button type="button" onClick={onClose}>Close</button>
</div>
);
}
Three things to get right: move focus inside on open, prevent Tab from escaping, restore previous focus on close. Radix UI Dialog handles all of this out of the box.
// Missing label — placeholder is not a substitute
<input
type="email"
placeholder="Enter your email"
/>
// Properly labeled with supplementary description
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email address
</label>
<input
id="email"
type="email"
placeholder="example@email.com"
aria-describedby="email-hint"
/>
<p id="email-hint" className="text-sm text-gray-500">
Use the email address you signed up with.
</p>
</div>
htmlFor paired with id connects the label to the input. Clicking the label moves focus to the field. Screen readers announce the label automatically when the input is focused. aria-describedby connects additional instructions. Placeholders disappear on input and aren't consistently read by screen readers—they can't replace labels.
axe DevTools (Chrome/Firefox extension) scans the current page and reports accessibility violations automatically. Color contrast failures, unlabeled inputs, improper role usage. It doesn't catch everything, but it gets the mechanical issues.
Lighthouse accessibility audit in Chrome DevTools produces a score with specific issues listed. It can be integrated into CI:
npx @lhci/cli autorun --config=lighthouserc.json
Keyboard testing directly: set the mouse aside and navigate the service from start to finish with only the keyboard. Is focus visible? Does it move in a logical order? Do modals close properly? Can forms be submitted?
Screen reader testing: VoiceOver on macOS (Command + F5), NVDA on Windows (free). The learning curve is steep at first but a few basic shortcuts are enough to spot major problems.
Most accessibility issues exist because developers never tried using the product without a mouse. Making that a habit—even briefly during development—catches more problems than any automated tool.
/* Never do this */
* {
outline: none;
}
/* Correct: hide for mouse clicks, show for keyboard focus */
:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
Removing outline globally blindfolds keyboard users. :focus-visible hides it for mouse interaction while preserving it for keyboard navigation.
Always write alt text. Meaningful images get descriptive text. Decorative images get alt="" so screen readers skip them. Icon-only buttons need aria-label to describe their purpose.
Check color contrast. Regular text needs at least 4.5:1 contrast against its background. Large text (18pt+ or bold 14pt+) needs 3:1. The WebAIM Contrast Checker or Figma plugins make this quick.
Use Radix UI or shadcn/ui. This is the most practical quick win of all. Radix UI is a headless component library built with accessibility as a baseline requirement. Dialog, DropdownMenu, Select, Tabs, Tooltip—all implement ARIA patterns correctly with built-in focus management. shadcn/ui adds styling on top. Using these instead of hand-rolling modal and dropdown components means you get accessibility without thinking about it.
The ramp analogy applies here too: a ramp built into the original design is not an accommodation—it's a structural feature that makes the building better for everyone. Radix and shadcn are the ramp. Accessibility isn't added on top; it's the default.
You have to use it with a keyboard to know. Tab through modals, forms, and dropdowns. Five minutes without a mouse reveals more than any checklist.
Semantic HTML is the foundation. <button>, <nav>, <main>, <label>—correct tags handle most accessibility requirements without any ARIA.
The first rule of ARIA is not to use it. Native HTML first. ARIA only for custom widgets with no native equivalent.
Focus traps are required in modals. Move focus in on open. Contain Tab within the modal. Restore focus on close. Radix Dialog does this automatically.
axe DevTools plus keyboard testing covers most ground. Perfect accessibility is a high bar, but these two together handle the majority of real-world issues.
Radix and shadcn give you accessibility for free. The cost of retrofitting accessibility later is higher than starting with accessible components.
I tried using my service with only a keyboard and watched focus jump to the wrong places. That wasn't just a UX annoyance. For some users, it was the difference between being able to use the product at all.