0
# Virtual Focus System
1
2
Support for aria-activedescendant focus patterns commonly used in comboboxes, listboxes, and other composite widgets where focus remains on a container while a descendant is highlighted.
3
4
## Capabilities
5
6
### moveVirtualFocus Function
7
8
Moves virtual focus from the current element to a target element, properly dispatching blur and focus events.
9
10
```typescript { .api }
11
/**
12
* Moves virtual focus from current element to target element.
13
* Dispatches appropriate virtual blur and focus events.
14
*/
15
function moveVirtualFocus(to: Element | null): void;
16
```
17
18
**Usage Examples:**
19
20
```typescript
21
import React, { useRef, useState } from "react";
22
import { moveVirtualFocus } from "@react-aria/focus";
23
24
// Listbox with virtual focus
25
function VirtualListbox({ options, value, onChange }) {
26
const listboxRef = useRef<HTMLDivElement>(null);
27
const [activeIndex, setActiveIndex] = useState(0);
28
const optionRefs = useRef<(HTMLDivElement | null)[]>([]);
29
30
const moveToOption = (index: number) => {
31
if (index >= 0 && index < options.length) {
32
const option = optionRefs.current[index];
33
if (option) {
34
moveVirtualFocus(option);
35
setActiveIndex(index);
36
37
// Update aria-activedescendant on the listbox
38
if (listboxRef.current) {
39
listboxRef.current.setAttribute('aria-activedescendant', option.id);
40
}
41
}
42
}
43
};
44
45
const handleKeyDown = (e: React.KeyboardEvent) => {
46
switch (e.key) {
47
case 'ArrowDown':
48
e.preventDefault();
49
moveToOption(Math.min(activeIndex + 1, options.length - 1));
50
break;
51
case 'ArrowUp':
52
e.preventDefault();
53
moveToOption(Math.max(activeIndex - 1, 0));
54
break;
55
case 'Enter':
56
case ' ':
57
e.preventDefault();
58
onChange(options[activeIndex]);
59
break;
60
}
61
};
62
63
return (
64
<div
65
ref={listboxRef}
66
role="listbox"
67
tabIndex={0}
68
onKeyDown={handleKeyDown}
69
aria-activedescendant={`option-${activeIndex}`}
70
>
71
{options.map((option, index) => (
72
<div
73
key={index}
74
ref={(el) => (optionRefs.current[index] = el)}
75
id={`option-${index}`}
76
role="option"
77
aria-selected={value === option}
78
onClick={() => {
79
moveToOption(index);
80
onChange(option);
81
}}
82
>
83
{option}
84
</div>
85
))}
86
</div>
87
);
88
}
89
90
// Combobox with virtual focus
91
function VirtualCombobox({ options, value, onChange }) {
92
const [isOpen, setIsOpen] = useState(false);
93
const [activeIndex, setActiveIndex] = useState(-1);
94
const inputRef = useRef<HTMLInputElement>(null);
95
const optionRefs = useRef<(HTMLDivElement | null)[]>([]);
96
97
const moveToOption = (index: number) => {
98
if (index >= 0 && index < options.length) {
99
const option = optionRefs.current[index];
100
if (option) {
101
moveVirtualFocus(option);
102
setActiveIndex(index);
103
104
// Update aria-activedescendant on the input
105
if (inputRef.current) {
106
inputRef.current.setAttribute('aria-activedescendant', option.id);
107
}
108
}
109
} else {
110
// Clear virtual focus
111
moveVirtualFocus(null);
112
setActiveIndex(-1);
113
if (inputRef.current) {
114
inputRef.current.removeAttribute('aria-activedescendant');
115
}
116
}
117
};
118
119
return (
120
<div className="combobox">
121
<input
122
ref={inputRef}
123
type="text"
124
role="combobox"
125
aria-expanded={isOpen}
126
aria-haspopup="listbox"
127
value={value}
128
onChange={(e) => onChange(e.target.value)}
129
onFocus={() => setIsOpen(true)}
130
onKeyDown={(e) => {
131
if (!isOpen) return;
132
133
switch (e.key) {
134
case 'ArrowDown':
135
e.preventDefault();
136
moveToOption(activeIndex + 1);
137
break;
138
case 'ArrowUp':
139
e.preventDefault();
140
moveToOption(activeIndex - 1);
141
break;
142
case 'Enter':
143
if (activeIndex >= 0) {
144
e.preventDefault();
145
onChange(options[activeIndex]);
146
setIsOpen(false);
147
}
148
break;
149
case 'Escape':
150
setIsOpen(false);
151
moveToOption(-1);
152
break;
153
}
154
}}
155
/>
156
{isOpen && (
157
<div role="listbox">
158
{options.map((option, index) => (
159
<div
160
key={index}
161
ref={(el) => (optionRefs.current[index] = el)}
162
id={`combobox-option-${index}`}
163
role="option"
164
onClick={() => {
165
onChange(option);
166
setIsOpen(false);
167
}}
168
>
169
{option}
170
</div>
171
))}
172
</div>
173
)}
174
</div>
175
);
176
}
177
```
178
179
### dispatchVirtualBlur Function
180
181
Dispatches virtual blur events on an element when virtual focus is moving away from it.
182
183
```typescript { .api }
184
/**
185
* Dispatches virtual blur events on element when virtual focus moves away.
186
*/
187
function dispatchVirtualBlur(from: Element, to: Element | null): void;
188
```
189
190
### dispatchVirtualFocus Function
191
192
Dispatches virtual focus events on an element when virtual focus is moving to it.
193
194
```typescript { .api }
195
/**
196
* Dispatches virtual focus events on element when virtual focus moves to it.
197
*/
198
function dispatchVirtualFocus(to: Element, from: Element | null): void;
199
```
200
201
**Usage Examples:**
202
203
```typescript
204
import React, { useRef } from "react";
205
import { dispatchVirtualBlur, dispatchVirtualFocus } from "@react-aria/focus";
206
207
// Custom virtual focus implementation
208
function CustomVirtualFocus({ items }) {
209
const [focusedIndex, setFocusedIndex] = useState(-1);
210
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
211
const previousFocusedRef = useRef<Element | null>(null);
212
213
const setVirtualFocus = (index: number) => {
214
const previousElement = previousFocusedRef.current;
215
const newElement = index >= 0 ? itemRefs.current[index] : null;
216
217
// Dispatch blur event on previously focused element
218
if (previousElement && previousElement !== newElement) {
219
dispatchVirtualBlur(previousElement, newElement);
220
}
221
222
// Dispatch focus event on newly focused element
223
if (newElement && newElement !== previousElement) {
224
dispatchVirtualFocus(newElement, previousElement);
225
}
226
227
previousFocusedRef.current = newElement;
228
setFocusedIndex(index);
229
};
230
231
return (
232
<div>
233
{items.map((item, index) => (
234
<div
235
key={index}
236
ref={(el) => (itemRefs.current[index] = el)}
237
onVirtualFocus={() => console.log(`Virtual focus on ${item}`)}
238
onVirtualBlur={() => console.log(`Virtual blur from ${item}`)}
239
onClick={() => setVirtualFocus(index)}
240
style={{
241
backgroundColor: focusedIndex === index ? '#e0e0e0' : 'transparent'
242
}}
243
>
244
{item}
245
</div>
246
))}
247
<button onClick={() => setVirtualFocus(-1)}>Clear Virtual Focus</button>
248
</div>
249
);
250
}
251
252
// Event listener example
253
function VirtualFocusEventListener() {
254
const itemRef = useRef<HTMLDivElement>(null);
255
256
useEffect(() => {
257
const element = itemRef.current;
258
if (!element) return;
259
260
const handleVirtualFocus = (e: Event) => {
261
console.log('Virtual focus received:', e);
262
element.classList.add('virtually-focused');
263
};
264
265
const handleVirtualBlur = (e: Event) => {
266
console.log('Virtual blur received:', e);
267
element.classList.remove('virtually-focused');
268
};
269
270
element.addEventListener('focus', handleVirtualFocus);
271
element.addEventListener('blur', handleVirtualBlur);
272
273
return () => {
274
element.removeEventListener('focus', handleVirtualFocus);
275
element.removeEventListener('blur', handleVirtualBlur);
276
};
277
}, []);
278
279
return (
280
<div>
281
<div ref={itemRef}>Virtual focusable item</div>
282
<button
283
onClick={() => dispatchVirtualFocus(itemRef.current!, null)}
284
>
285
Focus Item
286
</button>
287
<button
288
onClick={() => dispatchVirtualBlur(itemRef.current!, null)}
289
>
290
Blur Item
291
</button>
292
</div>
293
);
294
}
295
```
296
297
### getVirtuallyFocusedElement Function
298
299
Gets the currently virtually focused element using aria-activedescendant or the active element.
300
301
```typescript { .api }
302
/**
303
* Gets currently virtually focused element using aria-activedescendant
304
* or falls back to the active element.
305
*/
306
function getVirtuallyFocusedElement(document: Document): Element | null;
307
```
308
309
**Usage Examples:**
310
311
```typescript
312
import React, { useEffect, useState } from "react";
313
import { getVirtuallyFocusedElement } from "@react-aria/focus";
314
315
// Virtual focus tracker
316
function VirtualFocusTracker() {
317
const [virtuallyFocused, setVirtuallyFocused] = useState<Element | null>(null);
318
319
useEffect(() => {
320
const updateVirtualFocus = () => {
321
const element = getVirtuallyFocusedElement(document);
322
setVirtuallyFocused(element);
323
};
324
325
// Update on focus changes
326
document.addEventListener('focusin', updateVirtualFocus);
327
document.addEventListener('focusout', updateVirtualFocus);
328
329
// Update when aria-activedescendant changes
330
const observer = new MutationObserver((mutations) => {
331
for (const mutation of mutations) {
332
if (mutation.type === 'attributes' &&
333
mutation.attributeName === 'aria-activedescendant') {
334
updateVirtualFocus();
335
}
336
}
337
});
338
339
observer.observe(document.body, {
340
attributes: true,
341
subtree: true,
342
attributeFilter: ['aria-activedescendant']
343
});
344
345
updateVirtualFocus();
346
347
return () => {
348
document.removeEventListener('focusin', updateVirtualFocus);
349
document.removeEventListener('focusout', updateVirtualFocus);
350
observer.disconnect();
351
};
352
}, []);
353
354
return (
355
<div>
356
<p>Currently virtually focused element:</p>
357
<pre>{virtuallyFocused ? virtuallyFocused.outerHTML : 'None'}</pre>
358
</div>
359
);
360
}
361
362
// Focus synchronization
363
function FocusSynchronizer({ onVirtualFocusChange }) {
364
useEffect(() => {
365
const checkVirtualFocus = () => {
366
const element = getVirtuallyFocusedElement(document);
367
onVirtualFocusChange(element);
368
};
369
370
// Polling approach for demonstration
371
const interval = setInterval(checkVirtualFocus, 100);
372
373
return () => clearInterval(interval);
374
}, [onVirtualFocusChange]);
375
376
return null;
377
}
378
```
379
380
## Virtual Focus Patterns
381
382
### aria-activedescendant Pattern
383
384
The virtual focus system supports the aria-activedescendant pattern where:
385
- A container element maintains actual focus
386
- A descendant element is marked as "active" via aria-activedescendant
387
- Virtual focus events are dispatched on the active descendant
388
- Screen readers announce the active descendant as if it has focus
389
390
### Common Use Cases
391
392
**Listbox/Combobox:**
393
- Container input or div has actual focus
394
- Options are marked as active via aria-activedescendant
395
- Arrow keys change which option is virtually focused
396
397
**Grid/TreeGrid:**
398
- Grid container has actual focus
399
- Individual cells are virtually focused via aria-activedescendant
400
- Arrow keys navigate between cells
401
402
**Menu/Menubar:**
403
- Menu has actual focus
404
- Menu items are virtually focused
405
- Arrow keys and letters navigate items
406
407
### Event Dispatching
408
409
Virtual focus events are regular DOM events:
410
- `focus` event with `relatedTarget` set to previous element
411
- `blur` event with `relatedTarget` set to next element
412
- `focusin` and `focusout` events that bubble normally
413
- Events can be prevented with `preventDefault()`
414
415
### Screen Reader Support
416
417
Virtual focus is announced by screen readers when:
418
- The virtually focused element has proper ARIA roles
419
- The container has aria-activedescendant pointing to the virtual element
420
- The virtual element has appropriate labels and descriptions
421
- Focus events are properly dispatched for screen reader detection
422
423
### Performance Considerations
424
425
- Virtual focus avoids the performance cost of moving actual DOM focus
426
- Useful for large lists where moving focus would cause scrolling issues
427
- Reduces layout thrashing in complex UI components
428
- Allows custom focus styling without browser focus ring limitations