Keyboard Accessibility

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:

  1. Use semantic HTML when possible: Native elements have built-in accessibility
  2. Follow WAI-ARIA Authoring Practices: Established patterns users expect
  3. Implement all expected keyboard interactions: Not just Tab and Enter
  4. Provide visual focus indicators: Always visible, high contrast
  5. 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)
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

  1. Keyboard-only testing: Unplug your mouse
  2. Screen reader testing: NVDA, VoiceOver, JAWS
  3. Compare to native elements: Same or better experience
  4. 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?