Shoaib Khan

You don't need a redesign to ship real accessibility

4 min read
AccessibilityReactFrontend

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:

  1. Tab through your app - Can you reach everything? Is the order logical?
  2. Run Lighthouse a11y - Built into Chrome DevTools
  3. Run Axe - Browser extension catches common issues
  4. 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

  1. Start with HTML - Semantic elements give you accessibility for free
  2. Test with keyboard only - If it works without a mouse, you're 80% there
  3. Focus on forms - Most accessibility issues happen in forms
  4. Announce changes - Screen readers need to know when content updates
  5. Respect user preferences - Motion, contrast, font size
  6. 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.