При локализации пользовательского интерфейса внимание обычно сосредоточено на переводе видимого текста: заголовков, кнопок, сообщений об ошибках. Однако значительная часть интерфейса взаимодействует со вспомогательными технологиями через ARIA-атрибуты. Скринридеры, голосовые ассистенты и другие средства доступности используют именно эти значения для озвучивания элементов.
В приложениях на React с использованием FormatJS перевод ARIA-атрибутов становится частью полноценной системы интернационализации.
Наиболее распространённые ARIA-атрибуты, требующие локализации:
aria-labelaria-labelledbyaria-describedbyaria-placeholderaria-roledescriptionaria-valuetextБез перевода этих значений интерфейс становится частично недоступным: визуально язык может быть русским, а скринридер продолжит озвучивать элементы на английском.
aria-labelimport { useIntl } from 'react-intl';
function SearchButton() {
const intl = useIntl();
return (
<button
aria-label={intl.formatMessage({
id: 'search.button.label',
defaultMessage: 'Search'
})}
>
?
</button>
);
}
Видимое содержимое кнопки — иконка, поэтому скринридеру требуется текстовое описание.
FormattedMessage невозможно внутри ARIAКомпонент FormattedMessage возвращает React-элемент, а
ARIA-атрибут ожидает строку.
Неправильно:
<button
aria-label={
<FormattedMessage
id="close"
defaultMessage="Close"
/>
}
/>
Правильно использовать intl.formatMessage():
const label = intl.formatMessage({
id: 'close',
defaultMessage: 'Close'
});
<button aria-label={label} />
export const messages = {
closeButton: {
id: 'dialog.close',
defaultMessage: 'Close dialog'
},
nextSlide: {
id: 'carousel.next',
defaultMessage: 'Next slide'
}
};
Использование:
import { useIntl } from 'react-intl';
import { messages } from './messages';
function CloseButton() {
const intl = useIntl();
return (
<button
aria-label={intl.formatMessage(messages.closeButton)}
>
×
</button>
);
}
Такой подход особенно полезен при масштабировании приложения и автоматическом извлечении переводов.
ARIA-описания часто содержат переменные.
const label = intl.formatMessage(
{
id: 'notifications.count',
defaultMessage:
'You have {count} unread notifications'
},
{
count: unreadCount
}
);
<button aria-label={label}>
?
</button>
Файл перевода:
{
"notifications.count":
"У вас {count} непрочитанных уведомлений"
}
FormatJS поддерживает ICU MessageFormat, поэтому правила множественного числа работают и внутри ARIA-атрибутов.
const label = intl.formatMessage(
{
id: 'cart.items',
defaultMessage:
'{count, plural, ' +
'=0 {Cart is empty} ' +
'one {# item in cart} ' +
'other {# items in cart}}'
},
{
count
}
);
Русская локализация:
{
"cart.items":
"{count, plural, " +
"=0 {Корзина пуста} " +
"one {# товар в корзине} " +
"few {# товара в корзине} " +
"many {# товаров в корзине} " +
"other {# товара в корзине}}"
}
aria-describedbyconst errorMessage = intl.formatMessage({
id: 'email.error',
defaultMessage: 'Invalid email address'
});
<>
<input
aria-describedby="email-error"
/>
<div id="email-error">
{errorMessage}
</div>
</>
Здесь переводится не сам aria-describedby, а связанный
контент.
aria-placeholderХотя стандартный placeholder не относится к ARIA,
аналогичный принцип используется и для специальных
accessibility-атрибутов.
<input
aria-placeholder={intl.formatMessage({
id: 'search.placeholder',
defaultMessage: 'Enter search query'
})}
/>
aria-valuetextАтрибут используется в кастомных элементах управления: слайдерах, диапазонах, прогресс-барах.
const valueText = intl.formatMessage(
{
id: 'volume.level',
defaultMessage:
'Volume: {value}%'
},
{
value: volume
}
);
<div
role="slider"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={volume}
aria-valuetext={valueText}
/>
aria-roledescriptionАтрибут позволяет уточнить роль элемента.
<div
role="group"
aria-roledescription={
intl.formatMessage({
id: 'carousel.role',
defaultMessage: 'carousel'
})
}
/>
Русская локализация:
{
"carousel.role": "карусель"
}
SVG-иконки часто используются без текстового содержимого.
<svg
aria-label={intl.formatMessage({
id: 'download.icon',
defaultMessage: 'Download file'
})}
role="img"
>
Без локализации скринридер озвучит английскую строку даже в полностью русскоязычном приложении.
defineMessagesimport { defineMessages } from 'react-intl';
export const ariaMessages = defineMessages({
menuButton: {
id: 'aria.menu.button',
defaultMessage: 'Open navigation menu'
},
closeModal: {
id: 'aria.close.modal',
defaultMessage: 'Close modal window'
},
deleteItem: {
id: 'aria.delete.item',
defaultMessage: 'Delete item'
}
});
Использование:
<button
aria-label={
intl.formatMessage(
ariaMessages.menuButton
)
}
/>
FormatJS CLI способен извлекать сообщения из
formatMessage().
formatjs extract "src/**/*.{js,jsx,ts,tsx}"
ARIA-строки попадут в общий каталог переводов:
intl.formatMessage({
id: 'aria.search',
defaultMessage: 'Search'
});
Результат:
{
"aria.search": {
"defaultMessage": "Search"
}
}
id как части accessibility-архитектурыaria-labelledby и локализованным заголовкомconst title = intl.formatMessage({
id: 'dialog.title',
defaultMessage: 'Settings'
});
<>
<h2 id="settings-title">
{title}
</h2>
<div
role="dialog"
aria-labelledby="settings-title"
>
...
</div>
</>
Скринридер будет озвучивать локализованный заголовок автоматически.
При переключении локали ARIA-атрибуты обновляются так же, как обычный UI.
<IntlProvider
locale={locale}
messages={messages[locale]}
>
<App />
</IntlProvider>
После изменения locale:
langДля корректной озвучки необходимо синхронизировать язык документа.
<html lang="ru">
Без этого скринридер может читать русский перевод с английскими фонетическими правилами.
В React:
document.documentElement.lang = locale;
Плохой вариант:
aria-label="Button"
Скринридер озвучит бесполезную информацию.
Лучше:
aria-label="Удалить сообщение"
ARIA-текст должен описывать действие, а не тип элемента.
const label = expanded
? intl.formatMessage({
id: 'accordion.collapse',
defaultMessage: 'Collapse section'
})
: intl.formatMessage({
id: 'accordion.expand',
defaultMessage: 'Expand section'
});
<button
aria-expanded={expanded}
aria-label={label}
/>
ARIA не поддерживает HTML-разметку внутри строк.
Неправильно:
defaultMessage:
'Open <b>settings</b>'
ARIA должна получать только чистый текст.
При асинхронной загрузке локалей возможна ситуация, когда интерфейс уже отрисован, а ARIA-переводы ещё отсутствуют.
Нежелательное поведение:
aria-label="search.button"
Причина — отсутствие загруженного словаря.
Рекомендуется:
const label = intl.formatMessage(
{
id: 'profile.owner',
defaultMessage:
'{gender, select, ' +
'male {His profile} ' +
'female {Her profile} ' +
'other {Their profile}}'
},
{
gender
}
);
Русская версия:
{
"profile.owner":
"{gender, select, " +
"male {Его профиль} " +
"female {Её профиль} " +
"other {Их профиль}}"
}
При SSR переводы должны быть готовы до генерации HTML.
Пример для Next.js:
<IntlProvider
locale={locale}
messages={messages}
>
<Component {...pageProps} />
</IntlProvider>
Если сервер отдаёт английские ARIA-строки, а клиент позже переключает язык, скринридеры могут получить неконсистентный DOM.
expect(
screen.getByLabelText(
'Удалить сообщение'
)
).toBeInTheDocument();
Полезно тестировать отсутствие переводов.
<IntlProvider
locale="ru"
messages={{}}
>
Если отображаются message ID:
aria.delete.button
значит fallback настроен неправильно.
Плагин FormatJS помогает контролировать корректность сообщений.
Пример правила:
{
"formatjs/enforce-id": "error"
}
Это особенно важно для accessibility-сообщений, поскольку случайное
изменение id ломает локализацию ARIA.
type MessageIds =
| 'aria.search'
| 'aria.close'
| 'aria.delete';
intl.formatMessage({
id: 'aria.search'
});
Типизация предотвращает опечатки в идентификаторах.
Нежелательно создавать message descriptor внутри большого списка:
items.map(item => (
<button
aria-label={intl.formatMessage({
id: 'delete',
defaultMessage: 'Delete'
})}
/>
));
Лучше вынести сообщение:
const deleteLabel = intl.formatMessage({
id: 'delete',
defaultMessage: 'Delete'
});
ARIA может содержать локализованные даты.
const label = intl.formatDate(date, {
day: 'numeric',
month: 'long',
year: 'numeric'
});
<button aria-label={label} />
Для русского языка:
29 мая 2026 г.
const label = intl.formatNumber(price, {
style: 'currency',
currency: 'RUB'
});
Результат:
1 500,00 ₽
Такой текст корректно озвучивается скринридерами.
aria-live<div aria-live="polite">
{
intl.formatMessage({
id: 'upload.complete',
defaultMessage:
'File uploaded successfully'
})
}
</div>
Скринридер озвучит сообщение на текущем языке интерфейса.
<table
aria-label={intl.formatMessage({
id: 'users.table',
defaultMessage: 'Users table'
})}
>
<nav
aria-label={intl.formatMessage({
id: 'main.navigation',
defaultMessage: 'Main navigation'
})}
>
<div
role="dialog"
aria-label={intl.formatMessage({
id: 'profile.settings',
defaultMessage: 'Profile settings'
})}
>
<button aria-label="X">
X
</button>
Следует описывать действие:
<button aria-label="Закрыть окно">
X
</button>
Частая ситуация:
<button aria-label="Delete">
Удалить
</button>
Визуально интерфейс русский, accessibility — английский.
Плохо:
aria-label="
Нажмите сюда чтобы выполнить удаление
текущего элемента списка
"
Лучше:
aria-label="Удалить элемент"
В крупных проектах удобно выделять отдельные namespace:
aria.*
form.*
dialog.*
navigation.*
Пример:
{
"aria.close": "Закрыть",
"aria.search": "Поиск",
"aria.menu.open": "Открыть меню"
}
Такой подход упрощает поддержку accessibility-слоя приложения.