Building Accessible Frontend Components: Patterns, Patterns, Patterns
Building Accessible Frontend Components: Patterns, Patterns, Patterns
Creating accessible, robust, and delightful frontend components is one of the most valuable investments you can make in a UI library or app. This guide walk-throughs a practical set of patterns you can apply today, with real code you can copy-paste or adapt. We’ll cover accessible structure, keyboard interactions, focus management, and testing to ensure your components work for everyone.
1) Accessible component anatomy
A well-structured component has a clear API, semantic markup, and predictable behavior. Start with:
- Meaningful HTML: use native elements when possible (button, input, dialog) to leverage built-in accessibility features.
- ARIA as a last resort: only add ARIA attributes when there’s a gap between semantics and behavior.
- Clear focus indicators: visible outlines or custom styles that meet contrast guidelines.
- Keyboard operability: all interactive features should be reachable and usable with a keyboard.
Example: an accessible modal
- Use a hidden backdrop element that traps focus when the modal is open.
- Return focus to the trigger when closed.
- Provide aria-modal, role="dialog", and aria-label.
Code pattern (React):
import React, { useEffect, useRef } from 'react';
type ModalProps = {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
};
export function Modal({ open, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement | null>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (open) {
previouslyFocused.current = document.activeElement as HTMLElement;
// Escape to close
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
// Focus trap: focus first focusable element
const focusable = dialogRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusable?.focus();
return () => {
document.removeEventListener('keydown', onKey);
};
} else {
previouslyFocused.current?.focus();
}
}, [open, onClose]);
// Simple focus trap: keep tabbing inside
useEffect(() => {
if (!open) return;
const trap = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusables?.;
const last = focusables?.[focusables.length - 1];
if (!document.activeElement || !focusables) return;
if ((e.shiftKey && document.activeElement === first) || (!e.shiftKey && document.activeElement === last)) {
e.preventDefault();
(e.shiftKey ? last : first)?.focus();
}
};
document.addEventListener('keydown', trap);
return () => document.removeEventListener('keydown', trap);
}, [open]);
if (!open) return null;
return (
<div role="presentation" aria-label="Backdrop" style={backdropStyle}>
<div
role="dialog"
aria-modal="true"
aria-label={title}
ref={dialogRef}
style={dialogStyle}
>
<header style={headerStyle}>
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="Close modal" style={closeBtnStyle}>
×
</button>
</header>
<section aria-labelledby="modal-title" style={bodyStyle}>
{children}
</section>
</div>
</div>
);
}
// Simple inline styles for clarity; in real apps, use CSS modules or styled components.
const backdropStyle: React.CSSProperties = {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
};
const dialogStyle: React.CSSProperties = {
background: '#fff',
borderRadius: 8,
maxWidth: 600,
width: '90%',
boxShadow: '0 10px 25px rgba(0,0,0,.25)',
padding: 0,
};
const headerStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px 20px',
borderBottom: '1px solid #eee',
};
const closeBtnStyle: React.CSSProperties = {
border: 'none',
background: 'transparent',
fontSize: 20,
lineHeight: 1,
cursor: 'pointer',
};
const bodyStyle: React.CSSProperties = {
padding: 20,
};
Takeaways:
- Use role="dialog" and aria-modal="true".
- Trap focus within the modal and restore it when closed.
- Provide a clear, accessible close control. ### 2) Keyboard-first interactions
A keyboard-friendly component respects the user’s need to navigate without a mouse.
patterns:
- Logical focus order: the DOM order should reflect the visual order.
- Visible focus ring: ensure focus ring meets WCAG contrast guidelines.
- Alternative triggers: actions should be available via keyboard (Enter/Space, arrow keys for navigation).
Example: accessible dropdown
type DropdownItem = { label: string; value: string };
export function Dropdown({ items }: { items: DropdownItem[] }) {
const [open, setOpen] = React.useState(false);
const [idx, setIdx] = React.useState(0);
const btnRef = React.useRef<HTMLButtonElement | null>(null);
const listRef = React.useRef<HTMLUListElement | null>(null);
useEffect(() => {
if (open) {
const first = listRef.current?.querySelector<HTMLElement>('li button');
first?.focus();
}
}, [open]);
const onKeyDown = (e: React.KeyboardEvent) => {
if (!open) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setIdx((i) => Math.min(i + 1, items.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setIdx((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
// select item
alert(`Selected: ${items[idx].label}`);
setOpen(false);
btnRef.current?.focus();
} else if (e.key === 'Escape') {
setOpen(false);
btnRef.current?.focus();
}
};
return (
<div>
<button ref={btnRef} onClick={() => setOpen((s) => !s)} aria-expanded={open}>
Choose item
</button>
{open && (
<ul role="menu" ref={listRef} onKeyDown={onKeyDown} style={menuStyle}>
{items.map((it, i) => (
<li key={it.value} role="none">
<button
role="menuitem"
aria-selected={idx === i}
onClick={() => {
alert(`Selected: ${it.label}`);
setOpen(false);
btnRef.current?.focus();
}}
style={i === idx ? activeItemStyle : itemStyle}
>
{it.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}
const menuStyle: React.CSSProperties = {
marginTop: 8,
padding: 0,
listStyle: 'none',
border: '1px solid #ddd',
borderRadius: 6,
width: 200,
background: '#fff',
};
const itemStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
textAlign: 'left',
border: 'none',
background: 'transparent',
cursor: 'pointer',
};
const activeItemStyle: React.CSSProperties = {
...itemStyle,
background: '#f0f0f0',
};
Key ideas:
- Use ARIA roles for menus, and manage focus with keyboard events.
- Keep a consistent focus order and provide visual cues for the active item. ### 3) Focus management in single-page apps
Single-page apps (SPAs) can trap the user in sections. A simple pattern: restore focus to a sensible element when navigation or modals close.
- When opening a panel, store the currently focused element.
- Return focus to that element after closing.
- For in-page navigation, move focus to the first meaningful heading or interactive element.
Example: accessible tab panel
type Tab = { id: string; label: string; content: React.ReactNode };
export function Tabs({ tabs }: { tabs: Tab[] }) {
const [active, setActive] = React.useState(0);
const lastFocused = React.useRef<HTMLElement | null>(null);
const panelRef = React.useRef<HTMLDivElement | null>(null);
useEffect(() => {
// Save focus before changing tabs
const root = document.activeElement as HTMLElement | null;
lastFocused.current = root;
}, [active]);
useEffect(() => {
// Move focus to panel after activation
panelRef.current?.focus();
}, []);
const onKey = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowRight') setActive((i) => (i + 1) % tabs.length);
if (e.key === 'ArrowLeft') setActive((i) => (i - 1 + tabs.length) % tabs.length);
};
return (
<div>
<div role="tablist" aria-label="Sample tabs" onKeyDown={onKey}>
{tabs.map((t, i) => (
<button
key={t.id}
role="tab"
aria-selected={i === active}
aria-controls={`panel-${t.id}`}
id={`tab-${t.id}`}
onClick={() => setActive(i)}
>
{t.label}
</button>
))}
</div>
{tabs.map((t, i) => (
<section
role="tabpanel"
aria-labelledby={`tab-${t.id}`}
id={`panel-${t.id}`}
key={t.id}
hidden={i !== active}
tabIndex={-1}
ref={i === active ? panelRef : null}
>
{t.content}
</section>
))}
</div>
);
}
Tips:
- Use tab roles and aria-selected to indicate focus state.
- Manage focus transitions to maintain a predictable experience. ### 4) Testing for accessibility
Automated checks catch many issues, but human review matters. Combine:
- Automated tests: axe-core, lighthouse audits
- Unit tests: render components with accessibility props and simulate keyboard interactions
- Manual checks: screen reader feedback, high-contrast modes
Example: a simple ARIA-focused unit test (React Testing Library)
import { render, screen, fireEvent } from '@testing-library/react';
import { Dropdown } from './Dropdown';
test('dropdown opens and navigates with keyboard', () => {
const items = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
];
render(<Dropdown items={items} />);
const trigger = screen.getByRole('button', { name: /Choose item/i });
trigger.click();
const options = screen.getAllByRole('menuitem');
expect(options.length).toBe(3);
// Keyboard navigation
options.focus();
expect(document.activeElement).toBe(options);
// Activate a selection
fireEvent.keyDown(document.activeElement as Element, { key: 'Enter' });
// In real code, you would mock the selection handler
});
Recommendations:
- Integrate a11y checks into CI.
- Include accessibility test cases in your component test suites. ### 5) State management patterns for components
Avoid prop-drilling and too much internal state. Use a clean pattern:
- Uncontrolled vs. controlled: offer both modes where appropriate.
- Use a small, explicit state machine for complex components (open/closed, focused, loading, error).
- Expose callbacks for external control (onOpen, onClose, onChange).
Example: a controlled dropdown
export function ControlledDropdown({ value, onChange, items }: { value: string; onChange: (v: string) => void; items: DropdownItem[] }) {
const [open, setOpen] = React.useState(false);
return (
<div>
<button onClick={() => setOpen((s) => !s)} aria-expanded={open}>
{items.find((i) => i.value === value)?.label ?? 'Select'}
</button>
{open && (
<ul role="menu" aria-label="dropdown" style={menuStyle}>
{items.map((it) => (
<li key={it.value} role="none">
<button
role="menuitem"
onClick={() => {
onChange(it.value);
setOpen(false);
}}
>
{it.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}
Benefits:
- Clear API for consumers.
- Easier to test and reason about state transitions. ### 6) Performance considerations
Accessibility and performance go hand in hand:
- Avoid heavy rendering for hidden components; utilize conditional rendering rather than CSS display toggles.
- Debounce expensive updates in components that react to user input (e.g., live filtering).
- Use memoization for pure subcomponents to prevent unnecessary re-renders.
Example: memoized list render
type Item = { id: string; name: string };
export const ItemList = React.memo(function ItemList({ items }: { items: Item[] }) {
console.log('render list');
return (
<ul>
{items.map((it) => (
<li key={it.id}>{it.name}</li>
))}
</ul>
);
});
This prevents re-renders when parent state changes unrelated to the list.
7) Accessibility checklist
- Semantic HTML used where possible: buttons, lists, headings, landmarks.
- ARIA only where necessary; avoid over-annotation.
- Keyboard operability: all actions reachable via keyboard; provide visible focus.
- Focus management: restore focus on close/exit; trap focus in dialogs/panels.
- Visual contrast: ensure text and interactive elements meet WCAG contrast guidelines.
- Screen reader friendly: meaningful labels, roles, and relationships (aria-label, aria-labelledby, aria-describedby). ### 8) Practical example: an accessible collapsible panel
A collapsible panel demonstrates patterns we’ve covered: semantic structure, keyboard accessibility, and focus handling.
type PanelProps = {
id: string;
title: string;
children: React.ReactNode;
};
export function AccessiblePanel({ id, title, children }: PanelProps) {
const [open, setOpen] = React.useState(false);
const btnRef = React.useRef<HTMLButtonElement | null>(null);
const contentRef = React.useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (open) {
contentRef.current?.focus();
} else {
btnRef.current?.focus();
}
}, [open]);
return (
<section aria-labelledby={`${id}-header`}>
<h3 id={`${id}-header`}>
<button
ref={btnRef}
onClick={() => setOpen((s) => !s)}
aria-expanded={open}
aria-controls={`${id}-content`}
style={buttonStyle}
>
{title}
</button>
</h3>
{open && (
<div
id={`${id}-content`}
tabIndex={-1}
ref={contentRef}
role="region"
aria-label={title}
style={panelStyle}
>
{children}
</div>
)}
</section>
);
}
const buttonStyle: React.CSSProperties = {
background: 'transparent',
border: '1px solid #ccc',
padding: '6px 10px',
borderRadius: 6,
cursor: 'pointer',
};
const panelStyle: React.CSSProperties = {
padding: 12,
border: '1px solid #eee',
borderRadius: 6,
marginTop: 8,
};
This pattern shows how to structure for accessibility while keeping the UI intuitive.
If you’d like, I can tailor this into a longer blog post with a real project scaffold (e.g., a small component library demonstrating multiple accessible patterns), or adapt it to plain JavaScript, Vue, Svelte, or another framework you’re using. Which direction would you prefer?
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)