Widget Keyboard Navigation for ChatGPT Apps
Building accessible ChatGPT widgets requires robust keyboard navigation support. Power users rely on keyboard shortcuts for efficiency, while users with mobility impairments depend on keyboard access as their primary interaction method. Screen reader users navigate through keyboard commands, making proper focus management critical for usability.
The OpenAI Apps SDK requires WCAG AA compliance, which includes keyboard accessibility. Widgets must support full keyboard navigation without mouse dependency. This means implementing proper focus order, keyboard shortcuts, ARIA patterns, and focus management strategies that work seamlessly within ChatGPT's interface.
Keyboard navigation impacts user experience significantly. Well-implemented keyboard support improves task completion speed by 40-60% for power users. It enables users with disabilities to access your widget functionality independently. It reduces cognitive load by providing predictable navigation patterns that match platform conventions.
This guide provides production-ready implementations for focus management, keyboard shortcuts, ARIA patterns, and custom keyboard-accessible components. Each code example follows OpenAI's widget runtime requirements and WCAG 2.1 AA standards for comprehensive keyboard accessibility.
Focus Management Fundamentals
Focus management controls where keyboard input is directed within your widget. Proper focus management prevents keyboard traps, maintains logical tab order, and provides clear visual focus indicators. ChatGPT widgets must handle focus carefully to avoid interfering with the main ChatGPT interface.
Focus Trapping in Modal Widgets
Modal widgets require focus trapping to prevent keyboard navigation from escaping the widget boundaries. Focus should cycle through interactive elements within the modal and return to the first element after the last.
// FocusTrap.tsx - Focus trap component for modal widgets
import React, { useEffect, useRef, ReactNode } from 'react';
interface FocusTrapProps {
children: ReactNode;
active?: boolean;
onEscape?: () => void;
returnFocus?: boolean;
initialFocus?: string; // CSS selector
}
export const FocusTrap: React.FC<FocusTrapProps> = ({
children,
active = true,
onEscape,
returnFocus = true,
initialFocus
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!active || !containerRef.current) return;
// Store currently focused element for restoration
previousFocusRef.current = document.activeElement as HTMLElement;
// Get all focusable elements within trap
const getFocusableElements = (): HTMLElement[] => {
if (!containerRef.current) return [];
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
return Array.from(
containerRef.current.querySelectorAll(focusableSelectors)
) as HTMLElement[];
};
// Set initial focus
const setInitialFocus = () => {
if (!containerRef.current) return;
if (initialFocus) {
const element = containerRef.current.querySelector(
initialFocus
) as HTMLElement;
if (element) {
element.focus();
return;
}
}
// Default to first focusable element
const focusable = getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
}
};
setInitialFocus();
// Handle tab key navigation
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && onEscape) {
e.preventDefault();
onEscape();
return;
}
if (e.key !== 'Tab') return;
const focusable = getFocusableElements();
if (focusable.length === 0) return;
const firstElement = focusable[0];
const lastElement = focusable[focusable.length - 1];
const activeElement = document.activeElement as HTMLElement;
// Shift + Tab on first element -> focus last
if (e.shiftKey && activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
return;
}
// Tab on last element -> focus first
if (!e.shiftKey && activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
return;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus to previous element
if (returnFocus && previousFocusRef.current) {
previousFocusRef.current.focus();
}
};
}, [active, onEscape, returnFocus, initialFocus]);
return (
<div ref={containerRef} style={{ outline: 'none' }}>
{children}
</div>
);
};
// Usage example in ChatGPT widget
export const ModalWidget: React.FC = () => {
const [isOpen, setIsOpen] = React.useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>
Open Settings
</button>
{isOpen && (
<FocusTrap
active={isOpen}
onEscape={() => setIsOpen(false)}
returnFocus={true}
initialFocus="[data-initial-focus]"
>
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Widget Settings</h2>
<input
data-initial-focus
type="text"
placeholder="Search settings..."
/>
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
</FocusTrap>
)}
</>
);
};
Focus Restoration Strategy
Focus restoration returns keyboard focus to the triggering element after closing modal widgets or completing actions. This maintains navigation context and prevents user disorientation.
// useFocusRestoration.ts - Hook for focus restoration
import { useRef, useEffect } from 'react';
interface FocusRestorationOptions {
restoreOnUnmount?: boolean;
restoreDelay?: number;
}
export const useFocusRestoration = (
isActive: boolean,
options: FocusRestorationOptions = {}
) => {
const { restoreOnUnmount = true, restoreDelay = 0 } = options;
const previousFocusRef = useRef<HTMLElement | null>(null);
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
if (isActive) {
// Store current focus when becoming active
previousFocusRef.current = document.activeElement as HTMLElement;
} else if (previousFocusRef.current) {
// Restore focus when becoming inactive
if (restoreDelay > 0) {
timeoutRef.current = window.setTimeout(() => {
previousFocusRef.current?.focus();
previousFocusRef.current = null;
}, restoreDelay);
} else {
previousFocusRef.current.focus();
previousFocusRef.current = null;
}
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (restoreOnUnmount && previousFocusRef.current) {
previousFocusRef.current.focus();
}
};
}, [isActive, restoreOnUnmount, restoreDelay]);
return previousFocusRef;
};
Skip Links for Complex Widgets
Skip links allow keyboard users to bypass repetitive navigation and jump directly to main content areas. This is critical for complex widgets with navigation menus or toolbars.
// SkipLink.tsx - Skip link component
import React from 'react';
interface SkipLinkProps {
href: string;
children: string;
}
export const SkipLink: React.FC<SkipLinkProps> = ({ href, children }) => {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
const target = document.querySelector(href) as HTMLElement;
if (target) {
target.setAttribute('tabindex', '-1');
target.focus();
target.addEventListener('blur', () => {
target.removeAttribute('tabindex');
}, { once: true });
}
};
return (
<a
href={href}
onClick={handleClick}
style={{
position: 'absolute',
left: '-9999px',
zIndex: 999,
padding: '8px 16px',
background: '#000',
color: '#fff',
textDecoration: 'none'
}}
onFocus={(e) => {
e.currentTarget.style.left = '0';
}}
onBlur={(e) => {
e.currentTarget.style.left = '-9999px';
}}
>
{children}
</a>
);
};
Keyboard Shortcut Implementation
Keyboard shortcuts enhance productivity for power users and provide alternative navigation methods. ChatGPT widgets must implement shortcuts that don't conflict with platform shortcuts and provide customization options.
Keyboard Shortcut Manager
A centralized shortcut manager handles registration, conflict detection, and execution of keyboard shortcuts across your widget.
// KeyboardShortcutManager.ts - Centralized shortcut management
type KeyCombo = string; // e.g., "ctrl+k", "cmd+shift+f"
type ShortcutHandler = (e: KeyboardEvent) => void;
interface ShortcutConfig {
key: KeyCombo;
handler: ShortcutHandler;
description: string;
category?: string;
preventDefault?: boolean;
stopPropagation?: boolean;
enableInInputs?: boolean;
}
export class KeyboardShortcutManager {
private shortcuts = new Map<KeyCombo, ShortcutConfig>();
private enabled = true;
constructor() {
this.handleKeyDown = this.handleKeyDown.bind(this);
}
/**
* Normalize key combination to lowercase format
*/
private normalizeKeyCombo(combo: string): KeyCombo {
return combo
.toLowerCase()
.split('+')
.sort()
.join('+');
}
/**
* Extract key combination from keyboard event
*/
private getKeyComboFromEvent(e: KeyboardEvent): KeyCombo {
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push(e.metaKey ? 'cmd' : 'ctrl');
if (e.altKey) parts.push('alt');
if (e.shiftKey) parts.push('shift');
const key = e.key.toLowerCase();
if (!['control', 'alt', 'shift', 'meta'].includes(key)) {
parts.push(key);
}
return parts.sort().join('+');
}
/**
* Check if event target is an input element
*/
private isInputElement(target: EventTarget | null): boolean {
if (!target || !(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return (
tagName === 'input' ||
tagName === 'textarea' ||
tagName === 'select' ||
target.isContentEditable
);
}
/**
* Register a keyboard shortcut
*/
register(config: ShortcutConfig): () => void {
const normalizedKey = this.normalizeKeyCombo(config.key);
if (this.shortcuts.has(normalizedKey)) {
console.warn(
`Shortcut ${normalizedKey} already registered. Overwriting.`
);
}
this.shortcuts.set(normalizedKey, {
...config,
key: normalizedKey
});
return () => this.unregister(normalizedKey);
}
/**
* Unregister a keyboard shortcut
*/
unregister(key: KeyCombo): void {
const normalizedKey = this.normalizeKeyCombo(key);
this.shortcuts.delete(normalizedKey);
}
/**
* Handle keyboard event
*/
private handleKeyDown(e: KeyboardEvent): void {
if (!this.enabled) return;
const combo = this.getKeyComboFromEvent(e);
const config = this.shortcuts.get(combo);
if (!config) return;
// Skip if in input and not explicitly enabled
if (this.isInputElement(e.target) && !config.enableInInputs) {
return;
}
if (config.preventDefault) {
e.preventDefault();
}
if (config.stopPropagation) {
e.stopPropagation();
}
config.handler(e);
}
/**
* Start listening for keyboard events
*/
start(): void {
this.enabled = true;
document.addEventListener('keydown', this.handleKeyDown, true);
}
/**
* Stop listening for keyboard events
*/
stop(): void {
this.enabled = false;
document.removeEventListener('keydown', this.handleKeyDown, true);
}
/**
* Temporarily disable shortcuts
*/
disable(): void {
this.enabled = false;
}
/**
* Re-enable shortcuts
*/
enable(): void {
this.enabled = true;
}
/**
* Get all registered shortcuts grouped by category
*/
getShortcuts(): Record<string, ShortcutConfig[]> {
const grouped: Record<string, ShortcutConfig[]> = {};
this.shortcuts.forEach((config) => {
const category = config.category || 'General';
if (!grouped[category]) {
grouped[category] = [];
}
grouped[category].push(config);
});
return grouped;
}
}
// React hook for shortcut manager
import { useEffect, useMemo } from 'react';
export const useKeyboardShortcuts = (
shortcuts: Omit<ShortcutConfig, 'handler'>[] & { handler: ShortcutHandler }[]
) => {
const manager = useMemo(() => new KeyboardShortcutManager(), []);
useEffect(() => {
manager.start();
const unregisterFns = shortcuts.map(config =>
manager.register(config)
);
return () => {
unregisterFns.forEach(fn => fn());
manager.stop();
};
}, [manager, shortcuts]);
return manager;
};
Shortcut Help Menu Component
Provide discoverability for keyboard shortcuts through a help menu that displays all available shortcuts grouped by category.
// ShortcutHelpMenu.tsx - Keyboard shortcut reference
import React, { useState } from 'react';
import { KeyboardShortcutManager } from './KeyboardShortcutManager';
interface ShortcutHelpMenuProps {
manager: KeyboardShortcutManager;
}
export const ShortcutHelpMenu: React.FC<ShortcutHelpMenuProps> = ({
manager
}) => {
const [isOpen, setIsOpen] = useState(false);
const shortcuts = manager.getShortcuts();
const formatKeyCombo = (combo: string): string => {
return combo
.split('+')
.map(key => {
const keyMap: Record<string, string> = {
'cmd': '⌘',
'ctrl': 'Ctrl',
'alt': 'Alt',
'shift': '⇧'
};
return keyMap[key] || key.toUpperCase();
})
.join(' + ');
};
return (
<>
<button
onClick={() => setIsOpen(true)}
aria-label="Show keyboard shortcuts"
>
Keyboard Shortcuts
</button>
{isOpen && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-title"
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: '#fff',
padding: '24px',
borderRadius: '8px',
maxWidth: '600px',
maxHeight: '80vh',
overflow: 'auto'
}}
>
<h2 id="shortcuts-title">Keyboard Shortcuts</h2>
{Object.entries(shortcuts).map(([category, items]) => (
<div key={category} style={{ marginBottom: '24px' }}>
<h3>{category}</h3>
<dl style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
{items.map((shortcut) => (
<React.Fragment key={shortcut.key}>
<dt style={{ fontWeight: 'bold' }}>
{formatKeyCombo(shortcut.key)}
</dt>
<dd>{shortcut.description}</dd>
</React.Fragment>
))}
</dl>
</div>
))}
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
)}
</>
);
};
ARIA Patterns for Complex Widgets
ARIA (Accessible Rich Internet Applications) patterns provide standardized interaction models for complex UI components. These patterns ensure consistent keyboard navigation that matches user expectations.
Menu Pattern Implementation
The menu pattern provides keyboard navigation for dropdown menus with arrow key navigation and type-ahead support.
// AriaMenu.tsx - ARIA menu pattern implementation
import React, { useRef, useState, useEffect } from 'react';
interface MenuItem {
id: string;
label: string;
disabled?: boolean;
onSelect: () => void;
}
interface AriaMenuProps {
items: MenuItem[];
label: string;
}
export const AriaMenu: React.FC<AriaMenuProps> = ({ items, label }) => {
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(0);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const typeAheadRef = useRef('');
const typeAheadTimeoutRef = useRef<number>();
useEffect(() => {
if (isOpen && menuRef.current) {
// Focus first item when menu opens
itemRefs.current[focusedIndex]?.focus();
}
}, [isOpen, focusedIndex]);
const handleButtonKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
e.preventDefault();
setIsOpen(true);
setFocusedIndex(0);
break;
case 'ArrowUp':
e.preventDefault();
setIsOpen(true);
setFocusedIndex(items.length - 1);
break;
}
};
const handleMenuKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'Escape':
e.preventDefault();
setIsOpen(false);
buttonRef.current?.focus();
break;
case 'ArrowDown':
e.preventDefault();
setFocusedIndex(prev => {
const next = (prev + 1) % items.length;
itemRefs.current[next]?.focus();
return next;
});
break;
case 'ArrowUp':
e.preventDefault();
setFocusedIndex(prev => {
const next = prev === 0 ? items.length - 1 : prev - 1;
itemRefs.current[next]?.focus();
return next;
});
break;
case 'Home':
e.preventDefault();
setFocusedIndex(0);
itemRefs.current[0]?.focus();
break;
case 'End':
e.preventDefault();
setFocusedIndex(items.length - 1);
itemRefs.current[items.length - 1]?.focus();
break;
default:
// Type-ahead functionality
if (e.key.length === 1) {
handleTypeAhead(e.key);
}
}
};
const handleTypeAhead = (char: string) => {
clearTimeout(typeAheadTimeoutRef.current);
typeAheadRef.current += char.toLowerCase();
const matchIndex = items.findIndex((item, index) =>
index > focusedIndex &&
item.label.toLowerCase().startsWith(typeAheadRef.current)
);
if (matchIndex !== -1) {
setFocusedIndex(matchIndex);
itemRefs.current[matchIndex]?.focus();
}
typeAheadTimeoutRef.current = window.setTimeout(() => {
typeAheadRef.current = '';
}, 500);
};
const handleItemClick = (item: MenuItem) => {
if (item.disabled) return;
item.onSelect();
setIsOpen(false);
buttonRef.current?.focus();
};
return (
<div style={{ position: 'relative' }}>
<button
ref={buttonRef}
aria-haspopup="true"
aria-expanded={isOpen}
onKeyDown={handleButtonKeyDown}
onClick={() => setIsOpen(!isOpen)}
>
{label}
</button>
{isOpen && (
<div
ref={menuRef}
role="menu"
aria-label={label}
onKeyDown={handleMenuKeyDown}
style={{
position: 'absolute',
top: '100%',
left: 0,
background: '#fff',
border: '1px solid #ccc',
borderRadius: '4px',
padding: '4px 0',
minWidth: '200px'
}}
>
{items.map((item, index) => (
<button
key={item.id}
ref={el => itemRefs.current[index] = el}
role="menuitem"
disabled={item.disabled}
onClick={() => handleItemClick(item)}
style={{
width: '100%',
padding: '8px 16px',
border: 'none',
background: 'transparent',
textAlign: 'left',
cursor: item.disabled ? 'not-allowed' : 'pointer',
opacity: item.disabled ? 0.5 : 1
}}
>
{item.label}
</button>
))}
</div>
)}
</div>
);
};
Roving Tabindex Pattern
Roving tabindex allows arrow key navigation within a group of elements while maintaining a single tab stop for the group. This is ideal for toolbars, grid navigation, and button groups.
// useRovingTabindex.ts - Roving tabindex hook
import { useRef, useEffect, useState, RefObject } from 'react';
interface RovingTabindexOptions {
direction?: 'horizontal' | 'vertical' | 'both';
loop?: boolean;
onFocus?: (index: number) => void;
}
export const useRovingTabindex = <T extends HTMLElement>(
itemCount: number,
options: RovingTabindexOptions = {}
) => {
const {
direction = 'horizontal',
loop = true,
onFocus
} = options;
const [focusedIndex, setFocusedIndex] = useState(0);
const itemRefs = useRef<(T | null)[]>([]);
const setItemRef = (index: number) => (el: T | null) => {
itemRefs.current[index] = el;
};
const focusItem = (index: number) => {
if (index < 0 || index >= itemCount) return;
setFocusedIndex(index);
itemRefs.current[index]?.focus();
onFocus?.(index);
};
const handleKeyDown = (e: React.KeyboardEvent, currentIndex: number) => {
let handled = false;
switch (e.key) {
case 'ArrowRight':
if (direction === 'horizontal' || direction === 'both') {
e.preventDefault();
const nextRight = currentIndex + 1;
focusItem(
nextRight >= itemCount
? (loop ? 0 : currentIndex)
: nextRight
);
handled = true;
}
break;
case 'ArrowLeft':
if (direction === 'horizontal' || direction === 'both') {
e.preventDefault();
const nextLeft = currentIndex - 1;
focusItem(
nextLeft < 0
? (loop ? itemCount - 1 : currentIndex)
: nextLeft
);
handled = true;
}
break;
case 'ArrowDown':
if (direction === 'vertical' || direction === 'both') {
e.preventDefault();
const nextDown = currentIndex + 1;
focusItem(
nextDown >= itemCount
? (loop ? 0 : currentIndex)
: nextDown
);
handled = true;
}
break;
case 'ArrowUp':
if (direction === 'vertical' || direction === 'both') {
e.preventDefault();
const nextUp = currentIndex - 1;
focusItem(
nextUp < 0
? (loop ? itemCount - 1 : currentIndex)
: nextUp
);
handled = true;
}
break;
case 'Home':
e.preventDefault();
focusItem(0);
handled = true;
break;
case 'End':
e.preventDefault();
focusItem(itemCount - 1);
handled = true;
break;
}
return handled;
};
const getTabIndex = (index: number): number => {
return index === focusedIndex ? 0 : -1;
};
return {
focusedIndex,
setItemRef,
handleKeyDown,
getTabIndex,
focusItem
};
};
// Usage example
export const Toolbar: React.FC = () => {
const { setItemRef, handleKeyDown, getTabIndex } = useRovingTabindex<HTMLButtonElement>(
5,
{ direction: 'horizontal', loop: true }
);
return (
<div role="toolbar" aria-label="Formatting toolbar">
<button
ref={setItemRef(0)}
tabIndex={getTabIndex(0)}
onKeyDown={(e) => handleKeyDown(e, 0)}
aria-label="Bold"
>
Bold
</button>
<button
ref={setItemRef(1)}
tabIndex={getTabIndex(1)}
onKeyDown={(e) => handleKeyDown(e, 1)}
aria-label="Italic"
>
Italic
</button>
{/* Additional buttons... */}
</div>
);
};
Custom Keyboard-Accessible Components
Building custom components requires careful keyboard handling to match native browser behavior. These examples demonstrate production-ready implementations for common widget patterns.
Keyboard-Accessible Modal Dialog
Modal dialogs require focus trapping, escape key handling, and proper ARIA attributes for keyboard accessibility.
// KeyboardModal.tsx - Fully accessible modal component
import React, { useEffect, useRef } from 'react';
import { FocusTrap } from './FocusTrap';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
closeOnEscape?: boolean;
closeOnBackdropClick?: boolean;
}
export const KeyboardModal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
closeOnEscape = true,
closeOnBackdropClick = true
}) => {
const titleId = useRef(`modal-title-${Math.random().toString(36)}`);
useEffect(() => {
if (!isOpen) return;
// Prevent body scroll when modal open
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (closeOnBackdropClick && e.target === e.currentTarget) {
onClose();
}
};
if (!isOpen) return null;
return (
<div
role="presentation"
onClick={handleBackdropClick}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}
>
<FocusTrap
active={isOpen}
onEscape={closeOnEscape ? onClose : undefined}
returnFocus={true}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId.current}
style={{
background: '#fff',
borderRadius: '8px',
padding: '24px',
maxWidth: '500px',
width: '90%',
maxHeight: '90vh',
overflow: 'auto'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
<h2 id={titleId.current} style={{ margin: 0 }}>
{title}
</h2>
<button
onClick={onClose}
aria-label="Close dialog"
style={{
background: 'transparent',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
padding: '0',
width: '32px',
height: '32px'
}}
>
×
</button>
</div>
{children}
</div>
</FocusTrap>
</div>
);
};
Keyboard-Accessible Dropdown
Custom dropdowns require arrow key navigation, type-ahead search, and proper ARIA relationships.
// KeyboardDropdown.tsx - Accessible dropdown component
import React, { useState, useRef, useEffect } from 'react';
interface DropdownOption {
value: string;
label: string;
disabled?: boolean;
}
interface KeyboardDropdownProps {
options: DropdownOption[];
value: string;
onChange: (value: string) => void;
label: string;
placeholder?: string;
}
export const KeyboardDropdown: React.FC<KeyboardDropdownProps> = ({
options,
value,
onChange,
label,
placeholder = 'Select an option'
}) => {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const buttonRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const optionRefs = useRef<(HTMLLIElement | null)[]>([]);
const selectedOption = options.find(opt => opt.value === value);
useEffect(() => {
if (isOpen && highlightedIndex >= 0) {
optionRefs.current[highlightedIndex]?.scrollIntoView({
block: 'nearest'
});
}
}, [highlightedIndex, isOpen]);
const handleButtonKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case ' ':
case 'Enter':
case 'ArrowDown':
e.preventDefault();
setIsOpen(true);
setHighlightedIndex(
options.findIndex(opt => opt.value === value) || 0
);
break;
}
};
const handleListKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'Escape':
e.preventDefault();
setIsOpen(false);
buttonRef.current?.focus();
break;
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex(prev =>
prev < options.length - 1 ? prev + 1 : prev
);
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex(prev =>
prev > 0 ? prev - 1 : prev
);
break;
case 'Enter':
case ' ':
e.preventDefault();
if (!options[highlightedIndex].disabled) {
onChange(options[highlightedIndex].value);
setIsOpen(false);
buttonRef.current?.focus();
}
break;
case 'Home':
e.preventDefault();
setHighlightedIndex(0);
break;
case 'End':
e.preventDefault();
setHighlightedIndex(options.length - 1);
break;
}
};
return (
<div style={{ position: 'relative' }}>
<label id="dropdown-label">{label}</label>
<button
ref={buttonRef}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-labelledby="dropdown-label"
onKeyDown={handleButtonKeyDown}
onClick={() => setIsOpen(!isOpen)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: '4px',
background: '#fff',
textAlign: 'left',
cursor: 'pointer'
}}
>
{selectedOption?.label || placeholder}
</button>
{isOpen && (
<ul
ref={listRef}
role="listbox"
aria-labelledby="dropdown-label"
tabIndex={-1}
onKeyDown={handleListKeyDown}
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
margin: '4px 0 0',
padding: 0,
listStyle: 'none',
border: '1px solid #ccc',
borderRadius: '4px',
background: '#fff',
maxHeight: '200px',
overflow: 'auto',
zIndex: 100
}}
>
{options.map((option, index) => (
<li
key={option.value}
ref={el => optionRefs.current[index] = el}
role="option"
aria-selected={option.value === value}
aria-disabled={option.disabled}
onClick={() => {
if (!option.disabled) {
onChange(option.value);
setIsOpen(false);
buttonRef.current?.focus();
}
}}
onMouseEnter={() => setHighlightedIndex(index)}
style={{
padding: '8px 12px',
cursor: option.disabled ? 'not-allowed' : 'pointer',
background: highlightedIndex === index ? '#f0f0f0' : 'transparent',
opacity: option.disabled ? 0.5 : 1
}}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
};
Testing Keyboard Accessibility
Comprehensive testing ensures keyboard navigation works reliably across browsers and assistive technologies. Combine manual keyboard-only testing with automated accessibility tests.
Keyboard Testing Utilities
// keyboardTestUtils.ts - Testing utilities for keyboard navigation
import { fireEvent } from '@testing-library/react';
/**
* Keyboard event simulation utilities for testing
*/
export const keyboardTestUtils = {
/**
* Press a key with optional modifiers
*/
pressKey: (
element: Element,
key: string,
modifiers: {
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
} = {}
) => {
fireEvent.keyDown(element, {
key,
code: key,
...modifiers
});
},
/**
* Tab forward
*/
tab: (element: Element = document.body) => {
keyboardTestUtils.pressKey(element, 'Tab');
},
/**
* Tab backward (Shift+Tab)
*/
shiftTab: (element: Element = document.body) => {
keyboardTestUtils.pressKey(element, 'Tab', { shiftKey: true });
},
/**
* Press Enter key
*/
enter: (element: Element) => {
keyboardTestUtils.pressKey(element, 'Enter');
},
/**
* Press Escape key
*/
escape: (element: Element) => {
keyboardTestUtils.pressKey(element, 'Escape');
},
/**
* Press Space key
*/
space: (element: Element) => {
keyboardTestUtils.pressKey(element, ' ');
},
/**
* Arrow key navigation
*/
arrowDown: (element: Element) => {
keyboardTestUtils.pressKey(element, 'ArrowDown');
},
arrowUp: (element: Element) => {
keyboardTestUtils.pressKey(element, 'ArrowUp');
},
arrowLeft: (element: Element) => {
keyboardTestUtils.pressKey(element, 'ArrowLeft');
},
arrowRight: (element: Element) => {
keyboardTestUtils.pressKey(element, 'ArrowRight');
},
/**
* Get currently focused element
*/
getFocusedElement: (): Element | null => {
return document.activeElement;
},
/**
* Check if element is focused
*/
isFocused: (element: Element): boolean => {
return document.activeElement === element;
},
/**
* Get all focusable elements in container
*/
getFocusableElements: (container: Element): Element[] => {
const selectors = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
return Array.from(container.querySelectorAll(selectors));
},
/**
* Verify tab order matches expected order
*/
verifyTabOrder: (container: Element, expectedOrder: string[]): boolean => {
const focusable = keyboardTestUtils.getFocusableElements(container);
if (focusable.length !== expectedOrder.length) {
console.error(
`Expected ${expectedOrder.length} focusable elements, found ${focusable.length}`
);
return false;
}
return focusable.every((element, index) => {
const expectedSelector = expectedOrder[index];
const matches = element.matches(expectedSelector);
if (!matches) {
console.error(
`Tab order mismatch at index ${index}: expected "${expectedSelector}", found "${element.tagName}"`
);
}
return matches;
});
}
};
Manual Testing Checklist
Manual keyboard testing should cover these scenarios:
- Tab Navigation: Verify all interactive elements receive focus in logical order
- Escape Key: Confirm modals and menus close when pressing Escape
- Enter/Space: Test activation of buttons and links with both keys
- Arrow Keys: Validate navigation within menus, dropdowns, and custom controls
- Focus Indicators: Ensure visible focus outline on all interactive elements
- Focus Trapping: Verify focus stays within modals and doesn't escape
- Skip Links: Test skip navigation links appear on first Tab press
- Keyboard Shortcuts: Confirm shortcuts work without conflicting with browser shortcuts
Test with keyboard only (no mouse) to experience the interface as keyboard-dependent users do. Use browser developer tools to track focus movement and verify ARIA attributes are correctly applied.
Conclusion
Implementing comprehensive keyboard navigation transforms your ChatGPT widget into an accessible, professional application that serves all users effectively. Focus management ensures logical navigation flow, keyboard shortcuts enhance productivity, and ARIA patterns provide familiar interaction models.
Production-ready keyboard accessibility requires focus trapping for modals, restoration strategies for context preservation, and roving tabindex for complex components. The examples provided demonstrate battle-tested implementations that comply with WCAG AA standards and OpenAI's widget requirements.
Testing keyboard navigation through both manual keyboard-only workflows and automated accessibility tests catches issues before deployment. Combine the keyboard testing utilities with screen reader testing to validate complete accessibility compliance.
Ready to build accessible ChatGPT widgets with production-ready keyboard navigation? Start building with MakeAIHQ — our no-code platform generates WCAG-compliant ChatGPT apps with built-in keyboard accessibility, focus management, and ARIA patterns. From zero to ChatGPT App Store in 48 hours, with enterprise-grade accessibility included.
Related Resources
- Complete Guide to Building ChatGPT Applications
- Widget Accessibility and WCAG Compliance for ChatGPT
- React Widget Components for ChatGPT Apps
- ARIA Patterns for ChatGPT Widget Development
- Screen Reader Testing for ChatGPT Widgets
External References
- W3C ARIA Authoring Practices Guide
- WebAIM Keyboard Accessibility Guide
- MDN Focus Management Documentation
Last updated: December 2026