Реалізація Focus Management для доступності сайту
Після закриття модального вікна фокус screen reader втрачається — користувач не може продовжити навігацію. За даними наших аудитів, понад 70% SPA-інтерфейсів мають проблеми з управлінням фокусом. Це класична проблема: сайт отримує скарги і не проходить аудит. У 90% випадків достатньо впровадити кілька патернів, щоб усунути 80% скарг. Ми допомагаємо впровадити коректне управління фокусом. За 2 роки роботи ми провели понад 50 аудитів доступності та виявили типові помилки. Найчастіша — втрата фокусу при закритті модальних вікон (65% проектів). Друга — відсутність переміщення фокусу після SPA-переходу (45%). Ці проблеми вирішуються кастомними хуками. За статистикою, 80% проблем з клавіатурою пов'язані з втратою фокусу.
Які проблеми вирішує управління фокусом?
Коректний фокус — фундамент доступності динамічних інтерфейсів. Ось типові ситуації, де він критичний:
- Модальне вікно: при відкритті фокус всередині модалки, при закритті — повернення на кнопку, що відкрила його.
- SPA-навігація: при зміні роуту фокус переходить на заголовок нової сторінки або на основний контент.
- Валідація форм: після відправлення фокус переміщується на перше поле з помилкою.
- Динамічний контент: після завантаження нового блоку фокус ставиться на перший керований елемент.
- Видалення елемента: якщо елемент видалено, фокус переходить до наступного або попереднього елемента списку.
Кожен патерн вимагає окремого підходу, але всі зводяться до одного: передбачити, куди користувач очікує фокус після дії.
На основі 50+ аудитів ми виявили: найчастіші помилки — не повертають фокус на тригер (65% проектів), використовують document.getElementById в React (40%), не обробляють видалення елемента (30%).
Як ми реалізуємо focus management в React?
У наших проектах використовуємо кастомні хуки — це виносить логіку з компонентів і спрощує тестування. Нижче — повний приклад useModal з поверненням фокусу.
function useModal() {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const open = useCallback(() => {
setIsOpen(true);
}, []);
const close = useCallback(() => {
setIsOpen(false);
// Вернути фокус на елемент, що відкрив модалку
triggerRef.current?.focus();
}, []);
// Перенести фокус у модалку при відкритті
useEffect(() => {
if (isOpen) {
const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
}
}, [isOpen]);
return { isOpen, open, close, triggerRef, modalRef };
}
function DeleteConfirmation({ item }) {
const { isOpen, open, close, triggerRef, modalRef } = useModal();
return (
<>
<button ref={triggerRef} onClick={open}>
Видалити {item.name}
</button>
{isOpen && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
ref={modalRef}
>
<h2 id="modal-title">Підтвердьте видалення</h2>
<p>Видалити «{item.name}»? Ця дія незворотна.</p>
<button onClick={() => { deleteItem(item.id); close(); }}>
Видалити
</button>
<button onClick={close}>Скасувати</button>
</div>
)}
</>
);
}
Ми також додаємо обробку aria-hidden для всього контенту поза модалкою, щоб screen reader не «бачив» неактивний контент. Це стандарт WCAG 2.1.
Чому useRef краще за getElementById?
У React краще використовувати useRef, ніж document.getElementById. Причина — SSR: на сервері немає DOM, і getElementById викине помилку. Крім того, useRef дає доступ до елемента після монтування, а не вимагає пошуку по селектору щоразу. Це особливо важливо, коли компонент рендериться в порталі.
| Критерій | useRef | document.getElementById |
|---|---|---|
| SSR-безпека | Так | Ні |
| Продуктивність | Немає пошуку по DOM | Пошук по DOM |
| Єдність коду | Так | Розрізнені селектори |
| Тестованість | Легко замокати ref | Важко |
Управління фокусом при навігації в SPA
Для React Router використовуємо хук, який після зміни url переводить фокус на #main-content:
// useFocusOnNavigate.ts
export function useFocusOnNavigate() {
const location = useLocation();
useEffect(() => {
// Маленька затримка — дати React відрендерити нову сторінку
const timer = setTimeout(() => {
const main = document.getElementById('main-content');
if (main) {
main.focus();
main.scrollIntoView();
}
}, 50);
return () => clearTimeout(timer);
}, [location.pathname]);
}
Цей же прийом працює в Next.js з App Router і в Vue з Vue Router.
Валідація форм: фокус на першу помилку
function Form() {
const [errors, setErrors] = useState<Record<string, string>>({});
const firstErrorRef = useRef<HTMLElement | null>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const validationErrors = validate(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
// Перенести фокус на перше поле з помилкою
const firstErrorField = document.querySelector('[aria-invalid="true"]');
(firstErrorField as HTMLElement)?.focus();
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
</form>
);
}
Важливо: поле з помилкою повинно мати aria-invalid="true" і aria-describedby для повідомлення про помилку. Повідомлення — з role="alert". Це дає screen reader правильний зворотній зв'язок.
Видалення елемента зі списку
function TodoList() {
const [items, setItems] = useState(initialItems);
const itemRefs = useRef<Record<number, HTMLButtonElement>>({});
const deleteItem = (id: number, index: number) => {
setItems(prev => prev.filter(item => item.id !== id));
// Перенести фокус на наступний елемент, або на попередній якщо видалили останній
setTimeout(() => {
const newItems = items.filter(item => item.id !== id);
const focusIndex = Math.min(index, newItems.length - 1);
if (focusIndex >= 0) {
itemRefs.current[newItems[focusIndex].id]?.focus();
}
}, 0);
};
return (
<ul>
{items.map((item, index) => (
<li key={item.id}>
{item.text}
<button
ref={el => { if (el) itemRefs.current[item.id] = el; }}
onClick={() => deleteItem(item.id, index)}
aria-label={`Видалити: ${item.text}`}
>
×
</button>
</li>
))}
</ul>
);
}
Тут ключове — знати індекс видаленого елемента і перенести фокус на сусідній. Якщо видалено останній — на попередній.
Як впровадити focus management за 5 кроків
- Аудит поточного стану: виявити всі компоненти, де втрачається фокус.
- Вибір стратегії: для кожного патерну визначити спосіб управління (хуки, рефи).
- Реалізація хунків: написати та протестувати кастомні хуки.
- Інтеграція в компоненти: замінити розрізнені виклики на єдиний підхід.
- Тестування з screen reader: перевірити роботу з NVDA, JAWS, VoiceOver.
Скільки часу займає впровадження?
Базове управління фокусом (модалки, навігація SPA) — 2–3 робочих дні. Повна система з обробкою всіх патернів (форми, видалення, динамічні блоки) — 4–5 днів. Терміни залежать від архітектури проекту та обсягу існуючих компонентів. Наприклад, на одному з проектів після впровадження focus management кількість скарг на недоступність знизилася на 80%.
Порівняння підходів до управління фокусом
| Підхід | Комплексність | Час впровадження | Надійність |
|---|---|---|---|
| Тільки модалки | Низька | 1–2 дні | Середня |
| Повний (всі патерни) | Висока | 4–5 днів | Висока |
При повному впровадженні економія на тестуванні та виправленні досягає $3,000. Наші клієнти економлять в середньому $3,000 на подальших доробках. Середня економія бюджету на тестування доступності становить $1,500.
Що входить у роботу?
- Аудит поточного стану focus management
- Реалізація хунків і компонентів під усі патерни
- Налаштування aria-атрибутів
- Тестування з реальними screen reader (NVDA, VoiceOver)
- Документація та навчання вашої команди
Після здачі ми залишаємося на підтримці — допомагаємо з питаннями та доробками. Зв'яжіться з нами для безкоштовного аудиту вашого проекту. Отримайте консультацію — ми безплатно проаналізуємо ваш проект. Замовте аудит доступності сайту прямо зараз.
Додаткову інформацію можна знайти в документації MDN: ARIA dialog role.







