Building Accessible UIs with React and Tailwind
Let's talk about accessibility. Not the boring WCAG guidelines (though they're important), but the real stuff - building interfaces that actually work for everyone.
I used to think accessibility was just about adding some ARIA labels and calling it a day. Boy, was I wrong. After spending countless hours debugging why our app wouldn't work with screen readers, I've learned a thing or two about building truly accessible UIs.
The Real Deal with Accessibility
First off, accessibility isn't just about supporting screen readers. It's 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 here's the kicker - when you build for accessibility, you end up with 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's 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 can't. Plus, screen readers won't recognize them as clickable elements.
Forms That Actually Work
Here's a form component I use all the time that's both accessible and good-looking:
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 associated 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 in Action
Let me show you a modal component I recently built that's 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:
- Traps 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
Testing Your Work
Here's 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?
The Hard Truth
Building accessible UIs takes more time upfront. But it's way easier than retrofitting accessibility into an existing app (trust me, I've 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? Hit me up on Twitter. I'm always down to geek out about this stuff.