Пошук уроків, статей та іншого контенту
Керуватимете фокусом, клавіатурною навігацією та доступними станами інтерактивних елементів.
Фокус — це стан, за якого елемент отримує клавіатурні події та є поточною точкою взаємодії користувача. У документі одночасно може бути лише один document.activeElement.
Перевірити активний елемент можна так:
console.log(document.activeElement);Фокус можуть отримувати:
нативні інтерактивні елементи: button, a[href], input, select, textarea;
елементи з tabindex="0" або додатним
елементи, яким фокус надано програмно через element.focus().
Елемент із фокусом не обов’язково видно на екрані. Наприклад, він може перебувати всередині прокручуваного контейнера або бути перекритим іншим елементом. Тому доступність фокусу потрібно проєктувати окремо від візуального стилю.
focus() і blur()const searchInput = document.querySelector('#search');
searchInput.focus({
preventScroll: true
});
// Зазвичай вручну викликати blur() не потрібно.
// Фокус можна передати іншому елементу.
searchInput.blur();Опція preventScroll: true корисна, коли елемент потрібно сфокусувати, але не змінювати положення прокрутки.
Програмний фокус потрібен, зокрема, коли:
відкривається діалогове вікно;
після помилки потрібно перемістити увагу до повідомлення;
видаляється або приховується поточний елемент;
користувач переходить між елементами складного віджета клавіатурою.
Не слід викликати focus() безпосередньо під час завантаження сторінки без вагомої причини. Це може перехопити увагу користувача та змінити його очікуваний контекст.
tabindexАтрибут tabindex визначає, чи може елемент отримати фокус клавішею Tab.
tabindex="0" — елемент додається до звичайного порядку обходу документа;
tabindex="-1" — елемент не доступний через Tab, але може отримати програмний фокус;
додатні значення — елемент отримує штучний порядок фокусу.
Додатні значення tabindex майже завжди є помилкою:
<!-- Не рекомендовано -->
<button tabindex="3">Третій</button>
<button tabindex="1">Перший</button>
<button tabindex="2">Другий</button>Вони змушують підтримувати окремий порядок, який легко розходиться зі структурою DOM. Краще:
будувати правильний порядок у HTML;
використовувати tabindex="0" для кастомного інтерактивного елемента;
використовувати tabindex="-1" лише для керованого програмного фокусу.
Не варто перетворювати звичайний div на кнопку лише за допомогою tabindex. Якщо можливо, використовуйте нативний <button>:
<!-- Переважний варіант -->
<button type="button">Відкрити меню</button>
<!-- Кастомний елемент потребує додаткової поведінки -->
<div
role="button"
tabindex="0"
aria-label="Відкрити меню">
Відкрити меню
</div>Кастомний елемент із role="button" також має обробляти Enter і пробіл, мати доступний ім’я та коректний стан.
Не видаляйте обведення фокусу без повноцінної заміни:
/* Погано: користувач клавіатури втрачає орієнтир */
button:focus {
outline: none;
}Сучасний варіант — стилізувати :focus-visible. Цей псевдоклас зазвичай показує індикатор, коли взаємодія відбувається клавіатурою:
button:focus-visible,
[role="button"]:focus-visible,
a:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 3px;
}:focus і :focus-visible мають різне призначення:
:focus відповідає самому факту фокусування;
:focus-visible відповідає ситуації, у якій браузер вважає візуальний індикатор особливо потрібним.
Не покладайтеся лише на колір. Індикатор має мати достатній контраст, а бажано також змінювати товщину, контур або іншу візуальну властивість.
Для доступної клавіатурної взаємодії потрібно враховувати властивість event.key, а не коди клавіш:
element.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
activateElement();
}
});У такому обробнику:
Enter зазвичай активує елемент одразу;
пробіл часто активує кнопку після натискання;
preventDefault() для пробілу запобігає прокручуванню сторінки;
обробку потрібно додавати лише для кастомних контролів.
Для нативної кнопки не потрібно дублювати цю логіку: браузер уже підтримує правильну клавіатурну поведінку.
Клавіша Escape часто використовується для закриття тимчасових поверхонь:
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closePopover();
}
});Обробник варто реєструвати лише на час існування відповідного стану або перевіряти, чи поверхня справді відкрита.
Після відкриття модального діалогу необхідно:
перемістити фокус усередину діалогу;
не дозволити клавіші Tab вийти за межі діалогу;
обробити Escape;
повернути фокус на елемент, який відкрив діалог;
приховати фон від взаємодії та допоміжних технологій.
Атрибути діалогу:
<section
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title">
<h2 id="dialog-title">Підтвердження</h2>
</section>aria-modal="true" повідомляє допоміжним технологіям, що модальний діалог є поточним контекстом. Але сам по собі цей атрибут не блокує клавіатуру, мишу або програмний фокус. Поведінку потрібно реалізувати окремо.
Цей приклад можна зберегти як HTML-файл і відкрити в браузері.
<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Доступний діалог</title>
<style>
:root {
font-family: system-ui, sans-serif;
color-scheme: light;
}
body {
margin: 0;
padding: 2rem;
color: #17202a;
}
button {
font: inherit;
padding: 0.6rem 0.9rem;
border: 1px solid #697586;
border-radius: 0.4rem;
background: white;
cursor: pointer;
}
button:focus-visible,
input:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 3px;
}
.dialog-backdrop {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: rgb(0 0 0 / 55%);
}
.dialog-backdrop[hidden] {
display: none;
}
.dialog {
width: min(100%, 32rem);
padding: 1.5rem;
border-radius: 0.6rem;
background: white;
box-shadow: 0 1rem 3rem rgb(0 0 0 / 30%);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
</style>
</head>
<body>
<main id="page-content">
<h1>Налаштування профілю</h1>
<p>Зміни можна зберегти після підтвердження.</p>
<button id="open-dialog" type="button" aria-haspopup="dialog">
Видалити профіль
</button>
</main>
<div
id="dialog-backdrop"
class="dialog-backdrop"
hidden>
<section
id="confirm-dialog"
class="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-description">
<h2 id="dialog-title">Видалити профіль?</h2>
<p id="dialog-description">
Цю дію не можна скасувати.
</p>
<label>
Введіть слово «ВИДАЛИТИ»:
<input id="confirmation" type="text" autocomplete="off">
</label>
<div class="dialog-actions">
<button id="cancel-dialog" type="button">Скасувати</button>
<button id="confirm-delete" type="button" disabled>
Видалити
</button>
</div>
</section>
</div>
<script>
const pageContent = document.querySelector('#page-content');
const backdrop = document.querySelector('#dialog-backdrop');
const dialog = document.querySelector('#confirm-dialog');
const openButton = document.querySelector('#open-dialog');
const cancelButton = document.querySelector('#cancel-dialog');
const confirmation = document.querySelector('#confirmation');
const deleteButton = document.querySelector('#confirm-delete');
let previouslyFocusedElement = null;
function getFocusableElements(container) {
return [...container.querySelectorAll(
'button:not([disabled]), ' +
'[href], ' +
'input:not([disabled]), ' +
'select:not([disabled]), ' +
'textarea:not([disabled]), ' +
'[tabindex]:not([tabindex="-1"])'
)].filter((element) => {
return !element.hasAttribute('hidden') &&
element.getAttribute('aria-hidden') !== 'true';
});
}
function openDialog() {
previouslyFocusedElement = document.activeElement;
backdrop.hidden = false;
pageContent.inert = true;
confirmation.value = '';
deleteButton.disabled = true;
// Ставимо фокус на перше поле, щоб користувач одразу міг діяти.
confirmation.focus({ preventScroll: true });
}
function closeDialog() {
backdrop.hidden = true;
pageContent.inert = false;
if (
previouslyFocusedElement &&
previouslyFocusedElement.isConnected &&
!previouslyFocusedElement.matches(':disabled')
) {
previouslyFocusedElement.focus({ preventScroll: true });
}
}
openButton.addEventListener('click', openDialog);
cancelButton.addEventListener('click', closeDialog);
confirmation.addEventListener('input', () => {
deleteButton.disabled = confirmation.value !== 'ВИДАЛИТИ';
});
deleteButton.addEventListener('click', () => {
if (confirmation.value !== 'ВИДАЛИТИ') {
return;
}
closeDialog();
alert('Профіль видалено');
});
dialog.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
closeDialog();
return;
}
if (event.key !== 'Tab') {
return;
}
const focusableElements = getFocusableElements(dialog);
if (focusableElements.length === 0) {
event.preventDefault();
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements.at(-1);
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
});
</script>
</body>
</html>У прикладі:
hidden прибирає діалог із візуального дерева;
inert блокує взаємодію з фоновим вмістом;
фокус запам’ятовується перед відкриттям;
фокус циклічно переміщується між елементами діалогу;
Escape закриває діалог;
після закриття фокус повертається на кнопку;
disabled використано для кнопки, дія якої поки недоступна.
inert є кращим засобом для блокування фону, ніж лише aria-hidden. aria-hidden="true" впливає на доступне дерево, але не забороняє мишу, клавіатуру або програмний виклик focus().
hidden, display: none, visibility та aria-hiddenЦі механізми мають різне призначення.
hidden<div hidden>Вміст не відображається</div>Елемент не відображається та не бере участі у звичайній взаємодії.
display: noneЕлемент відсутній у візуальному представленні. Як правило, він також не доступний допоміжним технологіям.
visibility: hiddenЕлемент невидимий, але його поведінка та місце в макеті можуть відрізнятися від display: none. Не використовуйте це як універсальний спосіб керування доступністю.
aria-hidden="true"<div aria-hidden="true">
Візуально видимий, але прихований від допоміжних технологій текст
</div>Не встановлюйте aria-hidden="true" на елемент, який:
може отримати фокус;
містить фокусований елемент;
має інтерактивних нащадків, доступних користувачу.
Це створює суперечливий стан: клавіатура може потрапити в область, про яку скринрідер не повідомляє.
ARIA-атрибути описують стан або властивість елемента, але не створюють поведінку.
<button
type="button"
aria-expanded="false"
aria-controls="filters-panel">
Фільтри
</button>
<section id="filters-panel" hidden>
...
</section>Під час відкриття потрібно синхронно змінити обидва стани:
const toggle = document.querySelector('[aria-controls="filters-panel"]');
const panel = document.querySelector('#filters-panel');
toggle.addEventListener('click', () => {
const willOpen = panel.hidden;
panel.hidden = !willOpen;
toggle.setAttribute('aria-expanded', String(willOpen));
if (willOpen) {
panel.querySelector('input, button')?.focus();
}
});aria-expanded описує стан контролю, а hidden фактично змінює відображення. Не залишайте їх у суперечливих станах.
aria-disabled і disableddisabled — нативний стан HTML-елемента:
<button type="button" disabled>Зберегти</button>Такий елемент не активується звичайним способом і не потрапляє до стандартного порядку Tab.
aria-disabled="true" лише повідомляє допоміжним технологіям про недоступність:
<div
role="button"
tabindex="0"
aria-disabled="true">
Зберегти
</div>Обробник подій для такого елемента потрібно блокувати вручну:
customButton.addEventListener('click', (event) => {
if (customButton.getAttribute('aria-disabled') === 'true') {
event.preventDefault();
return;
}
save();
});Якщо нативного disabled достатньо, надавайте перевагу йому.
У меню, списках, вкладках і панелях зі стрілками всі елементи не обов’язково мають бути окремими зупинками для Tab.
Поширений патерн — roving tabindex:
один елемент має tabindex="0" і доступний через Tab;
інші мають tabindex="-1";
стрілки змінюють активний елемент;
після зміни фокус переміщується програмно.
Приклад горизонтального списку вкладок:
<div role="tablist" aria-label="Розділи профілю">
<button
id="tab-account"
role="tab"
aria-selected="true"
aria-controls="panel-account"
tabindex="0">
Обліковий запис
</button>
<button
id="tab-security"
role="tab"
aria-selected="false"
aria-controls="panel-security"
tabindex="-1">
Безпека
</button>
<button
id="tab-notifications"
role="tab"
aria-selected="false"
aria-controls="panel-notifications"
tabindex="-1">
Сповіщення
</button>
</div>const tablist = document.querySelector('[role="tablist"]');
const tabs = [...tablist.querySelectorAll('[role="tab"]')];
function activateTab(nextTab) {
for (const tab of tabs) {
const isActive = tab === nextTab;
tab.tabIndex = isActive ? 0 : -1;
tab.setAttribute('aria-selected', String(isActive));
const panel = document.getElementById(tab.getAttribute('aria-controls'));
panel.hidden = !isActive;
}
nextTab.focus();
}
tablist.addEventListener('keydown', (event) => {
const currentIndex = tabs.indexOf(document.activeElement);
if (currentIndex === -1) {
return;
}
let nextIndex;
if (event.key === 'ArrowRight') {
nextIndex = (currentIndex + 1) % tabs.length;
} else if (event.key === 'ArrowLeft') {
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
} else if (event.key === 'Home') {
nextIndex = 0;
} else if (event.key === 'End') {
nextIndex = tabs.length - 1;
} else {
return;
}
event.preventDefault();
activateTab(tabs[nextIndex]);
});Для різних віджетів правила можуть відрізнятися. Наприклад, у вкладках активація стрілкою може бути автоматичною або відкладеною до натискання Enter. Головне — послідовно реалізувати очікувану модель взаємодії та синхронізувати:
tabindex;
aria-selected;
видимість панелі;
фокус.
При видаленні або приховуванні сфокусованого елемента браузер може перемістити фокус на body. Для користувача це виглядає як втрата позиції.
Перед зміною DOM визначте, куди має перейти фокус:
const currentItem = document.querySelector('.task.is-focused');
const nextItem = currentItem.nextElementSibling;
currentItem.remove();
if (nextItem) {
nextItem.focus();
} else {
document.querySelector('#tasks-heading').focus();
}Заголовок, на який переходить фокус програмно, може мати tabindex="-1":
<h2 id="tasks-heading" tabindex="-1">Ваші завдання</h2>Це не додає заголовок до звичайного обходу клавішею Tab, але дозволяє перемістити на нього фокус після важливої зміни контексту.
Після валідації форми краще:
показати помилку біля відповідного поля;
зв’язати її з полем через aria-describedby;
за потреби перемістити фокус до першого поля з помилкою.
<label for="email">Електронна пошта</label>
<input
id="email"
name="email"
aria-invalid="true"
aria-describedby="email-error">
<p id="email-error">Введіть коректну адресу електронної пошти.</p>Не переміщуйте фокус після кожної невеликої зміни валідації: це заважає введенню тексту та клавіатурній навігації.
Події focus і blur не спливають у DOM. Для делегування використовуйте focusin і focusout:
document.querySelector('#form').addEventListener('focusin', (event) => {
const field = event.target.closest('input, select, textarea');
if (field) {
field.closest('.field')?.classList.add('is-focused');
}
});
document.querySelector('#form').addEventListener('focusout', (event) => {
const field = event.target.closest('input, select, textarea');
if (field) {
field.closest('.field')?.classList.remove('is-focused');
}
});focusout спрацьовує до того, як фокус остаточно залишає елемент. Якщо потрібно зрозуміти, куди він переходить, перевіряйте event.relatedTarget:
container.addEventListener('focusout', (event) => {
if (!container.contains(event.relatedTarget)) {
console.log('Фокус залишив контейнер');
}
});relatedTarget може бути null, наприклад коли фокус залишає документ або браузер не розкриває ціль через міркування безпеки.
У Shadow DOM фокус може бути всередині тіньового дерева, тоді як document.activeElement поверне елемент-хост:
const host = document.querySelector('user-card');
console.log(document.activeElement === host);Щоб знайти фактичний внутрішній елемент, можна дослідити shadowRoot.activeElement:
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement;
}
console.log(active);Під час побудови вебкомпонентів потрібно:
правильно визначати доступне ім’я хоста;
не приховувати фокусовані внутрішні елементи;
перевіряти поведінку Tab на межі Shadow DOM;
використовувати delegatesFocus лише коли така модель справді відповідає компоненту.
outlineПоганий контраст або повна відсутність індикатора фокусу робить сайт непридатним для клавіатурної навігації.
tabindextabindex="1" і подібні значення створюють непередбачуваний порядок. Для більшості кастомних елементів потрібні лише 0 або -1.
Користувач може залишитися на кнопці позаду модального вікна або взагалі втратити контекст.
Перед відновленням фокусу перевіряйте isConnected, а також те, що елемент не став disabled або прихованим.
aria-hidden для модального фонуaria-hidden не блокує клавіатуру та програмний фокус. Для блокування фону використовуйте inert і додатково перевіряйте поведінку в цільових браузерах.
role="button" не додає клавіатурної активації, натискання миші чи стану disabled. Нативні HTML-елементи зазвичай безпечніші та повніші.
aria-expanded без зміни інтерфейсуЯкщо aria-expanded="true", але панель прихована, стан для користувача скринрідера суперечить стану інтерфейсу.
Автоматичний фокус під час рендерингу або валідації може переривати введення та пересувати користувача без його дії.
Перевіряйте компонент у такому порядку:
Перезавантажте сторінку та не використовуйте мишу.
Пройдіть усі інтерактивні елементи клавішею Tab.
Перевірте зворотний рух через Shift + Tab.
Переконайтеся, що фокус завжди видимий.
Відкрийте діалоги, меню та списки лише клавіатурою.
Перевірте Escape, стрілки, Home, End, Enter і пробіл там, де вони очікувані.
Переконайтеся, що після закриття або видалення компонента фокус не губиться.
Перевірте, що приховані контроли не потрапляють у порядок Tab.
Перевірте стани aria-expanded, aria-selected, aria-checked, aria-invalid і aria-disabled.
Протестуйте інтерфейс зі скринрідером та зі збільшеним масштабом сторінки.
Керуйте фокусом лише там, де це допомагає користувачу зберегти контекст.
Будуйте природний порядок Tab структурою HTML, а не додатними значеннями tabindex.
Використовуйте нативні елементи замість імітації їх через ARIA.
Для модальних діалогів реалізуйте початковий фокус, пастку фокусу, Escape і повернення фокусу.
aria-* описують стан, але не створюють поведінку.
Для блокування фонового вмісту використовуйте inert, а не лише aria-hidden.
У складних віджетах синхронізуйте фокус, tabindex і ARIA-стани.
Під час динамічних змін DOM заздалегідь визначайте наступну логічну точку фокусу.
Видимий і контрастний індикатор фокусу є обов’язковою частиною інтерфейсу.