You don't need a redesign to ship real accessibility
60 minutes can unlock 10–20% more users.
If your app works without a mouse, announces changes, and respects user settings—you're already ahead.
Keyboard-first
Ensure every interactive element is reachable in a sane order. Keep focus outlines visible (:focus-visible). Add a "Skip to content" link.
/* Don't hide focus outlines */
button:focus-visible {
outline: 2px solid #3182ce;
outline-offset: 2px;
}
/* Skip to content link */
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: #000;
color: white;
padding: 8px;
text-decoration: none;
transition: top 0.3s;
}
.skip-link:focus {
top: 6px;
}
Semantic HTML > ARIA
Use button, a, label/input, fieldset/legend. Avoid clickable divs. ARIA is for gaps, not replacements.
// Good: Semantic HTML
<button onClick={handleSubmit}>Submit</button>
<a href="/dashboard">Dashboard</a>
// Bad: DIV with ARIA
<div role="button" onClick={handleSubmit} tabIndex={0} onKeyDown={handleKeyDown}>
Submit
</div>
Forms that speak
Every input needs a visible label. Associate with for/id. Show clear error text and link it with aria-describedby.
const LoginForm = () => {
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState("");
return (
<form>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-describedby={emailError ? "email-error" : undefined}
aria-invalid={!!emailError}
/>
{emailError && (
<div id="email-error" role="alert">
{emailError}
</div>
)}
</form>
);
};
Alt text that helps
Meaningful context for informative images; alt="" for decorative. Avoid "image of…".
// Good: Descriptive alt text
<img src="/chart.png" alt="Revenue increased 40% from Jan to Mar 2024" />
// Good: Decorative image
<img src="/decoration.svg" alt="" />
// Bad: Redundant alt text
<img src="/chart.png" alt="Image of a chart showing revenue data" />
Contrast that passes
Target 4.5:1. In Tailwind, prefer text-slate-800 over text-slate-400 on white. Validate with Axe/Lighthouse.
// Good: High contrast
<p className="text-slate-800">Primary content</p>
<p className="text-slate-600">Secondary content</p>
// Bad: Low contrast
<p className="text-slate-400">Hard to read content</p>
Focus management in SPAs
After route changes, move focus to the page heading so screen readers announce the new view.
const PageWithFocusManagement = () => {
const headingRef = useRef(null);
const location = useLocation();
useEffect(() => {
headingRef.current?.focus();
}, [location.pathname]);
return (
<main>
<h1 tabIndex={-1} ref={headingRef}>
Orders
</h1>
{/* Page content */}
</main>
);
};
Respect motion preferences
Reduce non-essential animations:
/* Respect user's motion preferences */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
In Tailwind:
<div className="transition-transform hover:scale-105 motion-reduce:transition-none motion-reduce:transform-none">
Hover me
</div>
Announce async updates
For toasts/status messages: role="status" or aria-live="polite" so screen readers hear them.
const Toast = ({ message, type }) => {
return (
<div
role={type === 'error' ? 'alert' : 'status'}
aria-live={type === 'error' ? 'assertive' : 'polite'}
className="toast"
>
{message}
</div>
);
};
// Usage
<Toast message="Profile updated successfully" type="success" />
<Toast message="Failed to save changes" type="error" />
Test fast
Quick testing workflow:
- Tab through your app - Can you reach everything? Is the order logical?
- Run Lighthouse a11y - Built into Chrome DevTools
- Run Axe - Browser extension catches common issues
- Try VoiceOver/NVDA for 5 minutes - Turn on screen reader, navigate your app
# Install axe-core for automated testing
npm install --save-dev @axe-core/react
# Add to your test setup
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
// Test example
test('should not have accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Key takeaways
- Start with HTML - Semantic elements give you accessibility for free
- Test with keyboard only - If it works without a mouse, you're 80% there
- Focus on forms - Most accessibility issues happen in forms
- Announce changes - Screen readers need to know when content updates
- Respect user preferences - Motion, contrast, font size
- Test early and often - 5 minutes of testing saves hours of refactoring
Accessibility isn't a feature you bolt on at the end—it's better UX for everyone. A keyboard-navigable app is faster for power users. Clear error messages help everyone. High contrast helps in bright sunlight.
Start small, test fast, ship iteratively. Your users will thank you.