Building Accessible UIs with React and Tailwind
Let's talk about accessibility. Not the boring WCAG guidelines (though they are important), but the real stuff. Basically building interfaces that actually work for everyone.
I used to think accessibility was just adding some ARIA labels and finishing the job. But I was totally wrong. After spending so many hours debugging why our app wouldn't work with screen readers, I learned a few things about building proper accessible UIs.
First thing, accessibility is not just about supporting screen readers. It is about building interfaces that work for:
- Someone using only their keyboard
- A person with shaky hands trying to click a tiny button
- Someone who can't distinguish between certain colors
- Users with slow internet trying to navigate your JS-heavy app
And the best part is, when you build for accessibility, you actually make better UIs for everyone. Let me show you how.
The Basics That Everyone Messes Up
Buttons vs Links
I see this mistake everywhere. I used to make it too. Here is the deal:
// 🚫 This is just wrong
<div onClick={handleClick} className="cursor-pointer">
Click Me
</div>
// 🚫 Also wrong - links aren't buttons
<a onClick={handleSubmit} href="#">
Submit Form
</a>
// ✅ This is the way
<button
onClick={handleClick}
type="button"
className="px-4 py-2 rounded-lg bg-blue-500 hover:bg-blue-600 focus:ring-2"
>
Click Me
</button>
// ✅ Links are for navigation
<Link href="/about" className="text-blue-500 hover:underline">
About Us
</Link>
Why does this matter? Try using your keyboard to navigate a site full of div-buttons. You simply can't. Plus, screen readers won't even recognize them as clickable elements.
Forms That Actually Work
Here is the form component I use all the time. It is both accessible and looks good:
export const TextField = ({ label, id, error, ...props }) => {
return (
<div className="space-y-1">
<label htmlFor={id} className="block text-sm font-medium text-gray-700">
{label}
{props.required && <span className="text-red-500">*</span>}
</label>
<input
id={id}
{...props}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? `${id}-error` : undefined}
className={`
w-full px-3 py-2 rounded-lg border
focus:ring-2 focus:ring-blue-500 focus:border-transparent
${error ? 'border-red-500' : 'border-gray-300'}
`}
/>
{error && (
<p id={`${id}-error`} className="text-sm text-red-500">
{error}
</p>
)}
</div>
);
};
The magic here is in the details:
- Labels are properly connected with inputs using
htmlFor - Error messages are linked to inputs using
aria-describedby - Visual error states match the semantic ones
- Focus states are clearly visible
Real-World Accessibility
Let me show you a modal component I recently built that is actually accessible:
export const Modal = ({ isOpen, onClose, title, children }) => {
const modalRef = useRef(null);
const previousFocus = useRef(null);
useEffect(() => {
if (isOpen) {
// Store the element that was focused before opening modal
previousFocus.current = document.activeElement;
// Focus the modal itself
modalRef.current?.focus();
// Trap focus inside modal
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableElements?.[0];
const lastFocusable = focusableElements?.[focusableElements.length - 1];
const handleTabKey = (e) => {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
lastFocusable?.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastFocusable) {
firstFocusable?.focus();
e.preventDefault();
}
}
}
};
document.addEventListener('keydown', handleTabKey);
return () => {
document.removeEventListener('keydown', handleTabKey);
// Restore focus when modal closes
previousFocus.current?.focus();
};
}
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-50">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="flex min-h-full items-center justify-center p-4">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl"
>
<h2 id="modal-title" className="text-xl font-semibold mb-4">
{title}
</h2>
{children}
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 rounded-full hover:bg-gray-100"
aria-label="Close modal"
>
<XIcon className="w-5 h-5" />
</button>
</div>
</div>
</div>,
document.body
);
};
This modal does a few important things:
- Keeps focus inside when open (you can't Tab out of it)
- Returns focus to the previous element when closed
- Works with screen readers
- Can be closed with Escape key
- Has proper ARIA attributes
Here is my quick accessibility testing routine:
- Unplug your mouse. Can you use your site with just Tab and Enter?
- Turn on VoiceOver (Mac) or NVDA (Windows). Does your site make sense?
- Install the axe DevTools extension. Fix the easy stuff it catches.
- Zoom your browser to 200%. Does everything still work?
Building accessible UIs takes more time upfront. But it is way easier than adding accessibility later to an existing app. Trust me, I have done both.
Start with these basics and build on them. Your future self and your users will thank you.
Want to chat more about accessible UI patterns? Message me on Twitter. always happy to discuss this stuff.
Used LLMs to correct grammar, typos etc 🤖