Accessibility means building websites that everyone can use, including people who rely on screen readers, keyboard navigation, or other assistive technology. In React, it’s easy to accidentally ship inaccessible code without realising it.
This is where TypeScript helps. Beyond preventing runtime bugs, TypeScript can also catch accessibility mistakes at compile time: wrong ARIA attributes, mis-typed events, invalid refs, and more.
This post is for React developers already using TypeScript who want to level up their accessibility practices. We’ll look at common pitfalls and show how TypeScript nudges you toward safer, more inclusive code, using small, practical examples you can apply right away.
By the end, you’ll know how to:
- Use semantic, strongly typed React elements.
- Prevent invalid ARIA attributes with TypeScript.
- Safely handle events and focus management with strict types.
Accessibility doesn’t have to be bolted on later. TypeScript helps you bake it into your code from the start.
Strongly typed semantic elements
One of the easiest ways to break accessibility in React is by reaching for a <div> when you should be using a semantic element like <button>. It might look the same, but you lose keyboard support, ARIA roles, and built-in accessibility features.
With TypeScript, the difference shows up immediately in the event types.
❌ Bad: using a <div> as a button
function FakeButton() {
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
console.log("Clicked", e.currentTarget);
};
return <div onClick={handleClick}>Click me</div>;
}
✅ Good: using a real <button>
function RealButton() {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log("Clicked", e.currentTarget);
};
return <button onClick={handleClick}>Click me</button>;
}
- Keyboard navigation works automatically.
- Screen readers announce it as a button.
- TypeScript ensures the event is tied to an actual
<button>.
Takeaway: TypeScript makes it obvious when you’re using the wrong element, and React’s typings encourage semantic HTML by giving you safer, stricter events. Accessibility often comes “for free” when you stick to the right element types.
“What if I add an ARIA role button on the div?”, I hear you ask.
This is one of those “technically possible, but don’t do it unless you have to” situations.
If you take a <div> and add role="button", you can make it accessible if you also reimplement keyboard and focus behavior. For example:
function DivAsButton() {
const handleClick = () => {
console.log("Activated!");
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
};
return (
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
Click me
</div>
);
}
This makes the <div> announce itself as a button, supports keyboard activation, and is technically accessible.
But here’s why it’s still inferior to a <button>:
- You’re reimplementing default behavior that comes “for free” with
<button>. - Easy to forget things like disabled state, focus styles, or ARIA interactions.
- TypeScript typing won’t stop you from leaving out
tabIndexoronKeyDown, so you’re still at risk of accessibility regressions.
✅ Best practice
Use a semantic <button> whenever possible. Only reach for role="button" on a <div> or <span> if you’re inside a highly constrained UI (e.g., custom canvas controls) where real buttons aren’t practical.
Type-safe ARIA with TypeScript
ARIA attributes make components more accessible but they’re also easy to misuse. A small typo (aria-labl instead of aria-label) or assigning an invalid value won’t throw a runtime error, but it will silently break accessibility.
TypeScript helps here because React ships with strictly typed ARIA props for all elements.
❌ Bad: typo and wrong value type
function BadInput() {
return (
<input
type="text"
aria-labl="Username" // ❌ typo, not recognised
aria-hidden="no" // ❌ should be boolean, not string
/>
);
}
TypeScript will underline both mistakes:
aria-labl→ Property does not exist."no"→ Type mismatch, expectedtrue | false.
✅ Good: correct ARIA usage
function GoodInput() {
return (
<input
type="text"
aria-label="Username"
aria-hidden={false}
/>
);
}
By leaning on TypeScript’s built-in JSX typings, you get immediate feedback in your editor before these issues ship.
Takeaway: TypeScript acts as an ARIA spellchecker, catching typos and invalid values that would otherwise slip through and break accessibility.
Safer event handling for accessibility
Event handling is another spot where accessibility bugs sneak in. For example, you might type your event handlers too loosely and miss important constraints, leading to broken keyboard navigation or incorrect assumptions about the target element.
❌ Bad: using a generic event type
function BadButton() {
const handleClick = (e: any) => {
// `any` lets anything through -- no guarantees
console.log(e.target.id);
};
return <button id="submit" onClick={handleClick}>Submit</button>;
}
Here e: any means:
- TypeScript won’t warn if you try to access non-existent properties.
- You lose autocomplete for accessibility-related props (like
currentTarget).
✅ Good: strongly typed event
function GoodButton() {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
// Safe access, compiler knows this is a button
console.log(e.currentTarget.id);
};
return <button id="submit" onClick={handleClick}>Submit</button>;
}
✅ Also works with keyboard events
function InputField() {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
console.log("Form submitted");
}
};
return <input type="text" onKeyDown={handleKeyDown} />;
}
Takeaway: By typing events precisely MouseEvent<HTMLButtonElement>, KeyboardEvent<HTMLInputElement>TypeScript makes sure your event handling matches the actual element. That reduces accessibility bugs like missing Enter key support or mis-typed handlers.
Managing Focus Safely with TypeScript
Focus management is critical for accessibility. Users who rely on keyboards or screen readers must always know where they are in the interface. In React, manually setting focus is common (e.g. after a modal opens), but it’s also easy to misuse refs without type safety.
❌ Bad: untyped ref, fragile focus call
function BadInputWithAutoFocus(props: IProps) {
const inputRef = React.useRef(null);
React.useEffect(() => {
// ❌ inputRef.current could be anything (or null)
inputRef.current.focus();
}, []);
return <input ref={inputRef} {...props} />;
}
inputRef.currentisany | null.- TypeScript can’t guarantee
.focus()exists. - Risk of runtime error if the ref isn’t attached yet.
✅ Good: typed ref with HTMLInputElement
function GoodInputWithAutoFocus(props: IProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
inputRef.current?.focus(); // ✅ safe
}, []);
return <input ref={inputRef} {...props} />;
}
Now:
inputRef.currentis always eitherHTMLInputElementornull..focus()is safely recognised by TypeScript.- Optional chaining (
?.) avoids crashes if the ref is not yet set.
Takeaway: Strongly typing refs in TypeScript makes focus management safer and more predictable so you don’t ship accessibility regressions when handling keyboard focus.
Linting + tooling integration
TypeScript catches type-level accessibility mistakes (like invalid ARIA attributes). But some issues require pattern-level enforcement, like forgetting an alt on an <img>. That’s where eslint-plugin-jsx-a11y comes in.
✅ TypeScript catches this:
<input aria-hidden="no" />
// ❌ Type '"no"' is not assignable to type 'boolean'.
✅ ESLint catches this:
<img src="/logo.png" />
// ❌ Error: Missing alt attribute.
Takeaways:
- TypeScript: Guards against invalid props/values.
- ESLint (eslint-plugin-jsx-a11y): Guards against missing accessibility patterns.
Together, they give you a strong safety net for accessible React code.
Case study: an accessible modal
Let’s put it all together. A modal needs ARIA attributes, focus management, and keyboard handling. With TypeScript, each part becomes safer.
/* Add this CSS once */
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0 0 1px 1px) !important;
clip-path: inset(50%) !important;
border: 0 !important;
white-space: nowrap !important;
}
import * as React from "react";
interface IProps {
onClose: () => void;
onConfirm: () => void;
triggerRef: React.RefObject<HTMLElement>;
}
export function AccessibleModal({ onClose, onConfirm, triggerRef }: IProps) {
const modalRef = React.useRef<HTMLDivElement>(null);
const liveRegionRef = React.useRef<HTMLDivElement>(null);
// Lock scroll and announce open; restore focus to trigger on close
React.useEffect(() => {
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
// Announce open
if (liveRegionRef.current) {
liveRegionRef.current.textContent = "Dialog opened. Beginning of dialog.";
}
return () => {
document.body.style.overflow = prevOverflow;
triggerRef.current?.focus();
};
}, [triggerRef]);
// Focus management + tab trap with wrap announcements
React.useEffect(() => {
const root = modalRef.current;
if (!root) return;
const selector =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
const getFocusable = (): HTMLElement[] =>
Array.from(root.querySelectorAll<HTMLElement>(selector)).filter(
(el) =>
!el.hasAttribute("disabled") &&
el.tabIndex !== -1 &&
// Optional: skip hidden elements
!(el as HTMLElement).offsetParent === false
);
// Initial focus (first focusable or dialog)
const initial = getFocusable();
(initial[0] ?? root).focus();
const onKeyDown = (e: KeyboardEvent) => {
// Close on Escape
if (e.key === "Escape") {
e.stopPropagation();
onClose();
return;
}
if (e.key !== "Tab") return;
if (!root.contains(document.activeElement)) return;
const els = getFocusable();
if (els.length === 0) {
e.preventDefault();
root.focus();
return;
}
if (els.length === 1) {
e.preventDefault();
els[0].focus();
return;
}
const first = els[0];
const last = els[els.length - 1];
// SHIFT+TAB on first: wrap to last (announce beginning→end)
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
if (liveRegionRef.current) {
liveRegionRef.current.textContent =
"Beginning of dialog. Wrapping to end.";
}
last.focus();
return;
}
// TAB on last: wrap to first (announce end→beginning)
if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
if (liveRegionRef.current) {
liveRegionRef.current.textContent =
"End of dialog. Wrapping to beginning.";
}
first.focus();
return;
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [onClose]);
const handleConfirm = () => {
onConfirm();
onClose();
};
return (
<>
{/* Polite live region for announcements */}
<div ref={liveRegionRef} aria-live="polite" className="sr-only" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-desc"
ref={modalRef}
tabIndex={-1}
>
<h2 id="modal-title">Confirm action</h2>
<p id="modal-desc" className="sr-only">
Are you sure you want to proceed?
</p>
<p>Are you sure you want to proceed?</p>
<button type="button" onClick={onClose}>
Cancel
</button>
<button type="button" onClick={handleConfirm}>
Confirm
</button>
</div>
</>
);
}
Focus management
- When the modal opens, focus is automatically moved to the modal container (
modalRef.current.focus()), so screen reader and keyboard users know they’re inside a dialog. - When the modal closes, focus is restored to the element that triggered it (
triggerRef.current.focus()), preserving context.
Focus trapping
- A custom
keydownlistener ensures keyboard focus stays inside the modal. - If the user presses
Tabon the last focusable element, focus jumps back to the first. - If
Shift+Tabon the first, it loops to the last. - This prevents users from “tabbing out” of the modal by accident.
Keyboard shortcuts
Pressing Escape calls onClose(), allowing the user to dismiss the modal without touching the mouse.
Screen reader support
- The modal uses proper WAI-ARIA attributes:
role="dialog": tells assistive tech this is a modal dialog.aria-modal="true": indicates it blocks background content.aria-labelledby="modal-title":links the dialog’s label to its heading. - A hidden live region announces when the modal is opened, so users are aware of the context change.
Action handling
- The “Cancel” button simply calls
onClose(). - The “Confirm” button calls
onConfirm()and then closes the modal. - Both buttons are keyboard-accessible and properly labeled.
Styling for accessibility
- Clear announcements: Users hear when they hit the beginning or end and that focus will wrap.
- Predictable focus: Works with 0/1/many focusable elements, and returns focus to the trigger on the close event.
Finally
Accessibility isn’t an afterthought. It’s baked into the way we write React + TypeScript code.
- TypeScript provides helpful feedback for accessibility issues, guiding you toward safer and more inclusive React code.
- ESLint + jsx-a11y complements this by catching higher-level patterns.
- Together, they reduce regressions and make accessibility the default outcome.
Bottom line: With React and TypeScript, accessibility isn’t just easier, It’s automatic when you lean on the type system and good tooling.
References:
TypeScript:
- Official website: https://www.typescriptlang.org/
- Documentation: https://www.typescriptlang.org/docs/
React:
- Official website: https://react.dev/
- Documentation: https://react.dev/learn
WAI-ARIA:
- W3C introduction: https://www.w3.org/WAI/standards-guidelines/aria/
- Authoring practices guide: https://www.w3.org/WAI/ARIA/apg/
ESLint:
- Official website: https://eslint.org/
- Documentation: https://eslint.org/docs/latest/
eslint-plugin-jsx-a11y
- NPM package: https://www.npmjs.com/package/eslint-plugin-jsx-a11y
- GitHub repository: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y
Looking for developers that build accessible React apps? Reach out