В России более 13 миллионов людей с инвалидностью. Многие из них пользуются интернетом через скринридеры (программы, озвучивающие содержимое экрана) или только клавиатурой. Если твой сайт недоступен для них, ты теряешь клиентов. В некоторых странах это ещё и нарушение закона.
Accessibility (a11y — 11 букв между a и y) — это создание интерфейсов, которыми могут пользоваться все люди вне зависимости от физических возможностей:
<!-- Скринридер скажет: "Изображение. Кроссовки Nike Air Max 90, белые" -->
<img src="sneaker.jpg" alt="Кроссовки Nike Air Max 90, белые" />
<!-- Декоративное — скринридер пропустит -->
<img src="decoration.svg" alt="" />
<!-- ПЛОХО — скринридер скажет "Изображение. sneaker.jpg" -->
<img src="sneaker.jpg" />Скринридер должен знать что означает каждое поле ввода. Без label пользователь слышит только «поле ввода» без контекста.
<!-- Правильно: label связан с input через for/id -->
<label for="phone">Номер телефона</label>
<input type="tel" id="phone" name="phone" />
<!-- Тоже правильно: input внутри label -->
<label>
Номер телефона
<input type="tel" name="phone" />
</label>
<!-- ПЛОХО: нет label -->
<input type="tel" name="phone" placeholder="Телефон" />Иногда видимый текст для кнопки невозможен — только иконка. Тогда используй aria-label:
<!-- Кнопка закрытия — только ×, скринридер скажет "Закрыть" -->
<button aria-label="Закрыть диалоговое окно">×</button>
<!-- Кнопка поиска с иконкой -->
<button aria-label="Поиск">🔍</button>
<!-- Ссылка с иконкой -->
<a href="/cart" aria-label="Корзина, 3 товара">🛒</a><h2 id="modal-title">Оформление заказа</h2>
<div role="dialog" aria-labelledby="modal-title">
<!-- Скринридер объявит: "Диалог. Оформление заказа" -->
</div><label for="password">Пароль</label>
<input type="password" id="password" aria-describedby="password-hint" />
<p id="password-hint">Минимум 8 символов, одна заглавная буква, одна цифра</p>Скринридер сначала зачитает метку («Пароль»), потом описание.
<!-- div как кнопка (лучше использовать настоящую button) -->
<div role="button" tabindex="0" onclick="submitForm()">
Отправить
</div>
<!-- Диалоговое окно -->
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Подтверждение</h2>
</div>
<!-- Предупреждение — скринридер озвучит немедленно -->
<div role="alert">Ошибка: неверный пароль</div><!-- Добавляем фокус элементу, который обычно не фокусируемый -->
<div tabindex="0" onclick="handleClick()">Кликабельный div</div>
<!-- Исключаем элемент из Tab-навигации -->
<button tabindex="-1">Только программный фокус</button>Ошибка 1: Только цвет как индикатор
<!-- Плохо: дальтоники не различат красный/зелёный -->
<span style="color: red">Ошибка</span>
<!-- Хорошо: добавляй текстовый или иконочный индикатор -->
<span>✕ Ошибка: неверный пароль</span>Ошибка 2: div вместо button
<!-- Плохо: div не фокусируется с клавиатуры, нет роли button -->
<div onclick="buy()">Купить</div>
<!-- Хорошо: button нативно доступен -->
<button onclick="buy()">Купить</button>Ошибка 3: aria-hidden на важном контенте
<!-- Скринридер пропустит этот элемент — только если он декоративный! -->
<div aria-hidden="true">Важная информация</div>Инструменты проверки: axe DevTools, Lighthouse (вкладка Accessibility), WAVE. Крупные компании (Сбер, Яндекс, Тинькофф) требуют WCAG 2.1 AA стандарт доступности. В React у каждого input должен быть htmlFor/aria-label.
Создание доступных элементов: кнопки, картинки, форма
// Доступная кнопка с иконкой
const closeBtn = document.createElement('button')
closeBtn.textContent = '×'
closeBtn.setAttribute('aria-label', 'Закрыть модальное окно')
closeBtn.type = 'button'
console.log('Текст кнопки:', closeBtn.textContent)
console.log('aria-label:', closeBtn.getAttribute('aria-label'))
console.log('Доступна с клавиатуры: да (button нативно фокусируемая)')
// Доступное изображение
const img = document.createElement('img')
img.src = 'https://example.com/sneaker.jpg'
img.alt = 'Кроссовки Nike Air Max 90, белые, вид сбоку'
img.width = 400
img.height = 400
console.log('img alt:', img.alt)
console.log('Alt информативен:', img.alt.length > 10 ? 'да' : 'нет')
// Доступная форма
const form = document.createElement('form')
const label = document.createElement('label')
label.htmlFor = 'email-field'
label.textContent = 'Электронная почта'
const input = document.createElement('input')
input.type = 'email'
input.id = 'email-field'
input.name = 'email'
input.placeholder = 'you@example.com'
input.required = true
const hint = document.createElement('p')
hint.id = 'email-hint'
hint.textContent = 'Введи реальный email — на него придёт подтверждение'
input.setAttribute('aria-describedby', 'email-hint')
form.append(label, input, hint)
console.log('label.for === input.id:', label.htmlFor === input.id)
console.log('aria-describedby:', input.getAttribute('aria-describedby'))Аудит доступности — проверка основных требований
// Аудит доступности элементов
function auditA11y(elements) {
let score = 0
const totalChecks = elements.length
const issues = []
elements.forEach(el => {
const tag = el.tag
const attrs = el.attrs || {}
if (tag === 'img') {
if (attrs.alt !== undefined) {
score++
if (attrs.alt === '') {
console.log('OK img: декоративное (alt="")')
} else {
console.log('OK img: alt="' + attrs.alt.substring(0, 30) + '..."')
}
} else {
issues.push('ОШИБКА img: нет атрибута alt')
}
}
if (tag === 'button') {
const hasText = attrs.text && attrs.text.trim()
const hasAriaLabel = attrs['aria-label']
if (hasText || hasAriaLabel) {
score++
console.log('OK button: "' + (attrs['aria-label'] || attrs.text) + '"')
} else {
issues.push('ОШИБКА button: нет текста и нет aria-label')
}
}
if (tag === 'input') {
if (attrs['aria-label'] || attrs.labelFor) {
score++
console.log('OK input: есть подпись')
} else {
issues.push('ОШИБКА input: нет label или aria-label')
}
}
})
issues.forEach(i => console.log(i))
console.log('Доступность: ' + score + '/' + totalChecks + ' (' + Math.round(score/totalChecks*100) + '%)')
}
auditA11y([
{ tag: 'img', attrs: { alt: 'Логотип компании', src: 'logo.png' } },
{ tag: 'img', attrs: { src: 'hero.jpg' } }, // Нет alt
{ tag: 'button', attrs: { text: '×', 'aria-label': 'Закрыть' } },
{ tag: 'button', attrs: { text: '' } }, // Нет текста
{ tag: 'input', attrs: { type: 'email', labelFor: 'email' } },
{ tag: 'input', attrs: { type: 'text' } }, // Нет label
])В России более 13 миллионов людей с инвалидностью. Многие из них пользуются интернетом через скринридеры (программы, озвучивающие содержимое экрана) или только клавиатурой. Если твой сайт недоступен для них, ты теряешь клиентов. В некоторых странах это ещё и нарушение закона.
Accessibility (a11y — 11 букв между a и y) — это создание интерфейсов, которыми могут пользоваться все люди вне зависимости от физических возможностей:
<!-- Скринридер скажет: "Изображение. Кроссовки Nike Air Max 90, белые" -->
<img src="sneaker.jpg" alt="Кроссовки Nike Air Max 90, белые" />
<!-- Декоративное — скринридер пропустит -->
<img src="decoration.svg" alt="" />
<!-- ПЛОХО — скринридер скажет "Изображение. sneaker.jpg" -->
<img src="sneaker.jpg" />Скринридер должен знать что означает каждое поле ввода. Без label пользователь слышит только «поле ввода» без контекста.
<!-- Правильно: label связан с input через for/id -->
<label for="phone">Номер телефона</label>
<input type="tel" id="phone" name="phone" />
<!-- Тоже правильно: input внутри label -->
<label>
Номер телефона
<input type="tel" name="phone" />
</label>
<!-- ПЛОХО: нет label -->
<input type="tel" name="phone" placeholder="Телефон" />Иногда видимый текст для кнопки невозможен — только иконка. Тогда используй aria-label:
<!-- Кнопка закрытия — только ×, скринридер скажет "Закрыть" -->
<button aria-label="Закрыть диалоговое окно">×</button>
<!-- Кнопка поиска с иконкой -->
<button aria-label="Поиск">🔍</button>
<!-- Ссылка с иконкой -->
<a href="/cart" aria-label="Корзина, 3 товара">🛒</a><h2 id="modal-title">Оформление заказа</h2>
<div role="dialog" aria-labelledby="modal-title">
<!-- Скринридер объявит: "Диалог. Оформление заказа" -->
</div><label for="password">Пароль</label>
<input type="password" id="password" aria-describedby="password-hint" />
<p id="password-hint">Минимум 8 символов, одна заглавная буква, одна цифра</p>Скринридер сначала зачитает метку («Пароль»), потом описание.
<!-- div как кнопка (лучше использовать настоящую button) -->
<div role="button" tabindex="0" onclick="submitForm()">
Отправить
</div>
<!-- Диалоговое окно -->
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Подтверждение</h2>
</div>
<!-- Предупреждение — скринридер озвучит немедленно -->
<div role="alert">Ошибка: неверный пароль</div><!-- Добавляем фокус элементу, который обычно не фокусируемый -->
<div tabindex="0" onclick="handleClick()">Кликабельный div</div>
<!-- Исключаем элемент из Tab-навигации -->
<button tabindex="-1">Только программный фокус</button>Ошибка 1: Только цвет как индикатор
<!-- Плохо: дальтоники не различат красный/зелёный -->
<span style="color: red">Ошибка</span>
<!-- Хорошо: добавляй текстовый или иконочный индикатор -->
<span>✕ Ошибка: неверный пароль</span>Ошибка 2: div вместо button
<!-- Плохо: div не фокусируется с клавиатуры, нет роли button -->
<div onclick="buy()">Купить</div>
<!-- Хорошо: button нативно доступен -->
<button onclick="buy()">Купить</button>Ошибка 3: aria-hidden на важном контенте
<!-- Скринридер пропустит этот элемент — только если он декоративный! -->
<div aria-hidden="true">Важная информация</div>Инструменты проверки: axe DevTools, Lighthouse (вкладка Accessibility), WAVE. Крупные компании (Сбер, Яндекс, Тинькофф) требуют WCAG 2.1 AA стандарт доступности. В React у каждого input должен быть htmlFor/aria-label.
Создание доступных элементов: кнопки, картинки, форма
// Доступная кнопка с иконкой
const closeBtn = document.createElement('button')
closeBtn.textContent = '×'
closeBtn.setAttribute('aria-label', 'Закрыть модальное окно')
closeBtn.type = 'button'
console.log('Текст кнопки:', closeBtn.textContent)
console.log('aria-label:', closeBtn.getAttribute('aria-label'))
console.log('Доступна с клавиатуры: да (button нативно фокусируемая)')
// Доступное изображение
const img = document.createElement('img')
img.src = 'https://example.com/sneaker.jpg'
img.alt = 'Кроссовки Nike Air Max 90, белые, вид сбоку'
img.width = 400
img.height = 400
console.log('img alt:', img.alt)
console.log('Alt информативен:', img.alt.length > 10 ? 'да' : 'нет')
// Доступная форма
const form = document.createElement('form')
const label = document.createElement('label')
label.htmlFor = 'email-field'
label.textContent = 'Электронная почта'
const input = document.createElement('input')
input.type = 'email'
input.id = 'email-field'
input.name = 'email'
input.placeholder = 'you@example.com'
input.required = true
const hint = document.createElement('p')
hint.id = 'email-hint'
hint.textContent = 'Введи реальный email — на него придёт подтверждение'
input.setAttribute('aria-describedby', 'email-hint')
form.append(label, input, hint)
console.log('label.for === input.id:', label.htmlFor === input.id)
console.log('aria-describedby:', input.getAttribute('aria-describedby'))Аудит доступности — проверка основных требований
// Аудит доступности элементов
function auditA11y(elements) {
let score = 0
const totalChecks = elements.length
const issues = []
elements.forEach(el => {
const tag = el.tag
const attrs = el.attrs || {}
if (tag === 'img') {
if (attrs.alt !== undefined) {
score++
if (attrs.alt === '') {
console.log('OK img: декоративное (alt="")')
} else {
console.log('OK img: alt="' + attrs.alt.substring(0, 30) + '..."')
}
} else {
issues.push('ОШИБКА img: нет атрибута alt')
}
}
if (tag === 'button') {
const hasText = attrs.text && attrs.text.trim()
const hasAriaLabel = attrs['aria-label']
if (hasText || hasAriaLabel) {
score++
console.log('OK button: "' + (attrs['aria-label'] || attrs.text) + '"')
} else {
issues.push('ОШИБКА button: нет текста и нет aria-label')
}
}
if (tag === 'input') {
if (attrs['aria-label'] || attrs.labelFor) {
score++
console.log('OK input: есть подпись')
} else {
issues.push('ОШИБКА input: нет label или aria-label')
}
}
})
issues.forEach(i => console.log(i))
console.log('Доступность: ' + score + '/' + totalChecks + ' (' + Math.round(score/totalChecks*100) + '%)')
}
auditA11y([
{ tag: 'img', attrs: { alt: 'Логотип компании', src: 'logo.png' } },
{ tag: 'img', attrs: { src: 'hero.jpg' } }, // Нет alt
{ tag: 'button', attrs: { text: '×', 'aria-label': 'Закрыть' } },
{ tag: 'button', attrs: { text: '' } }, // Нет текста
{ tag: 'input', attrs: { type: 'email', labelFor: 'email' } },
{ tag: 'input', attrs: { type: 'text' } }, // Нет label
])Напиши доступный HTML-блок поиска. Включи: кнопку открытия поиска (только иконка 🔍) с aria-label="Открыть поиск", форму с label (for="search-input") + input (type="search", id="search-input", aria-describedby="search-hint") + p-подсказку (id="search-hint") + кнопку submit с aria-label="Найти".
aria-label на кнопке с иконкой: aria-label="Открыть поиск". label for и input id должны совпадать: for="search-input" и id="search-input". aria-describedby указывает на id подсказки: aria-describedby="search-hint". Кнопка submit: aria-label="Найти".