Custom Widget Keyboard Support
18 min read
Building Keyboard-Accessible Custom Widgets
When native HTML elements don't meet your design needs, you may need to build custom widgets. Making these widgets fully keyboard accessible requires careful implementation of focus management, keyboard handlers, and ARIA attributes. This guide provides patterns for common custom widgets.
General Principles
Before diving into specific widgets, understand these principles:
- Use semantic HTML when possible: Native elements have built-in accessibility
- Follow WAI-ARIA Authoring Practices: Established patterns users expect
- Implement all expected keyboard interactions: Not just Tab and Enter
- Provide visual focus indicators: Always visible, high contrast
- Test with keyboard and screen readers: Essential, not optional
Custom Dropdown/Select
Native <select> limited? Build an accessible alternative:
jsx
function CustomSelect({ options, value, onChange, label }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const buttonRef = useRef(null);
const listRef = useRef(null);
const handleKeyDown = (e) => {
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen && activeIndex >= 0) {
onChange(options[activeIndex].value);
setIsOpen(false);
buttonRef.current?.focus();
} else {
setIsOpen(true);
setActiveIndex(0);
}
break;
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(0);
} else {
setActiveIndex((prev) =>
Math.min(prev + 1, options.length - 1)
);
}
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, 0));
break;
case 'Escape':
setIsOpen(false);
buttonRef.current?.focus();
break;
case 'Home':
e.preventDefault();
setActiveIndex(0);
break;
case 'End':
e.preventDefault();
setActiveIndex(options.length - 1);
break;
}
};
return (
<div className="custom-select" onKeyDown={handleKeyDown}>
<label id="select-label">{label}</label>
<button
ref={buttonRef}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-labelledby="select-label"
onClick={() => setIsOpen(!isOpen)}
>
{options.find(o => o.value === value)?.label || 'Select...'}
</button>
{isOpen && (
<ul
ref={listRef}
role="listbox"
aria-labelledby="select-label"
aria-activedescendant={`option-${activeIndex}`}
>
{options.map((option, index) => (
<li
key={option.value}
id={`option-${index}`}
role="option"
aria-selected={value === option.value}
className={activeIndex === index ? 'active' : ''}
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}Keyboard interactions:
- Enter/Space: Open menu or select focused option
- Arrow Down/Up: Navigate options
- Home/End: Jump to first/last option
- Escape: Close menu
- Type characters: Jump to matching option
Tab Panel
jsx
function TabPanel({ tabs }) {
const [activeTab, setActiveTab] = useState(0);
const tabRefs = useRef([]);
const handleKeyDown = (e, index) => {
let newIndex = index;
switch (e.key) {
case 'ArrowRight':
newIndex = (index + 1) % tabs.length;
break;
case 'ArrowLeft':
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
setActiveTab(newIndex);
tabRefs.current[newIndex]?.focus();
};
return (
<div>
<div role="tablist" aria-label="Content tabs">
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(el) => (tabRefs.current[index] = el)}
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeTab === index}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.title}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab.id}
id={`panel-${tab.id}`}
role="tabpanel"
aria-labelledby={`tab-${tab.id}`}
tabIndex={0}
hidden={activeTab !== index}
>
{tab.content}
</div>
))}
</div>
);
}Keyboard interactions:
- Tab: Move into/out of tab list
- Arrow Left/Right: Navigate between tabs
- Home/End: First/last tab
- Enter/Space: Activate tab (if not automatic)
Modal Dialog
jsx
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const previousActiveElement = useRef(null);
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement;
// Find first focusable element
const focusable = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusable?.[0]?.focus();
}
return () => {
if (isOpen) {
previousActiveElement.current?.focus();
}
};
}, [isOpen]);
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key === 'Tab') {
// Trap focus
const focusable = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusable?.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}Keyboard interactions:
- Tab/Shift+Tab: Navigate within modal (trapped)
- Escape: Close modal
- Focus returns to trigger element on close
Slider/Range Input
jsx
function Slider({ min, max, value, onChange, label, step = 1 }) {
const handleKeyDown = (e) => {
let newValue = value;
const bigStep = (max - min) / 10;
switch (e.key) {
case 'ArrowRight':
case 'ArrowUp':
newValue = Math.min(value + step, max);
break;
case 'ArrowLeft':
case 'ArrowDown':
newValue = Math.max(value - step, min);
break;
case 'PageUp':
newValue = Math.min(value + bigStep, max);
break;
case 'PageDown':
newValue = Math.max(value - bigStep, min);
break;
case 'Home':
newValue = min;
break;
case 'End':
newValue = max;
break;
default:
return;
}
e.preventDefault();
onChange(newValue);
};
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="slider-container">
<label id="slider-label">{label}: {value}</label>
<div
role="slider"
tabIndex={0}
aria-labelledby="slider-label"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={`${value}`}
onKeyDown={handleKeyDown}
className="slider-track"
>
<div
className="slider-thumb"
style={{ left: `${percentage}%` }}
/>
</div>
</div>
);
}Keyboard interactions:
- Arrow Right/Up: Increase value
- Arrow Left/Down: Decrease value
- Page Up/Down: Large increment/decrement
- Home/End: Min/max value
Testing Custom Widgets
- Keyboard-only testing: Unplug your mouse
- Screen reader testing: NVDA, VoiceOver, JAWS
- Compare to native elements: Same or better experience
- Follow WAI-ARIA patterns: Users expect these interactions
Widget Checklist
- All keyboard interactions documented and implemented
- Focus management correct (trapped when needed)
- ARIA roles, states, and properties correct
- Focus visible on all interactive elements
- Works with screen readers
- Native elements used where possible
Was this article helpful?