ARIA атрибуты и переводы

При локализации пользовательского интерфейса внимание обычно сосредоточено на переводе видимого текста: заголовков, кнопок, сообщений об ошибках. Однако значительная часть интерфейса взаимодействует со вспомогательными технологиями через ARIA-атрибуты. Скринридеры, голосовые ассистенты и другие средства доступности используют именно эти значения для озвучивания элементов.

В приложениях на React с использованием FormatJS перевод ARIA-атрибутов становится частью полноценной системы интернационализации.

Наиболее распространённые ARIA-атрибуты, требующие локализации:

  • aria-label
  • aria-labelledby
  • aria-describedby
  • aria-placeholder
  • aria-roledescription
  • aria-valuetext

Без перевода этих значений интерфейс становится частично недоступным: визуально язык может быть русским, а скринридер продолжит озвучивать элементы на английском.


Перевод aria-label

Базовый пример

import { 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-значений

ARIA-описания часто содержат переменные.

Пример со счётчиком уведомлений

const label = intl.formatMessage(
  {
    id: 'notifications.count',
    defaultMessage:
      'You have {count} unread notifications'
  },
  {
    count: unreadCount
  }
);

<button aria-label={label}>
  ?
</button>

Файл перевода:

{
  "notifications.count":
    "У вас {count} непрочитанных уведомлений"
}

Плюрализация внутри ARIA

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-describedby

Описание полей формы

const 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-иконки часто используются без текстового содержимого.

<svg
  aria-label={intl.formatMessage({
    id: 'download.icon',
    defaultMessage: 'Download file'
  })}
  role="img"
>

Без локализации скринридер озвучит английскую строку даже в полностью русскоязычном приложении.


Работа с defineMessages

Группировка ARIA-переводов

import { 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:

  • обновляется текст интерфейса;
  • изменяются ARIA-описания;
  • скринридеры начинают озвучивать новые переводы.

Влияние HTML-атрибута lang

Для корректной озвучки необходимо синхронизировать язык документа.

<html lang="ru">

Без этого скринридер может читать русский перевод с английскими фонетическими правилами.

В React:

document.documentElement.lang = locale;

Проблемы коротких ARIA-описаний

Плохой вариант:

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}
/>

Использование Rich Text внутри accessibility

ARIA не поддерживает HTML-разметку внутри строк.

Неправильно:

defaultMessage:
  'Open <b>settings</b>'

ARIA должна получать только чистый текст.


Ленивая загрузка переводов и accessibility

При асинхронной загрузке локалей возможна ситуация, когда интерфейс уже отрисован, а ARIA-переводы ещё отсутствуют.

Нежелательное поведение:

aria-label="search.button"

Причина — отсутствие загруженного словаря.

Рекомендуется:

  • не рендерить приложение до загрузки локали;
  • использовать fallback-переводы;
  • избегать отображения message ID в production.

Accessibility и ICU MessageFormat

Выбор пола

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 {Их профиль}}"
}

ARIA и серверный рендеринг

При SSR переводы должны быть готовы до генерации HTML.

Пример для Next.js:

<IntlProvider
  locale={locale}
  messages={messages}
>
  <Component {...pageProps} />
</IntlProvider>

Если сервер отдаёт английские ARIA-строки, а клиент позже переключает язык, скринридеры могут получить неконсистентный DOM.


Тестирование accessibility-переводов

Проверка через Testing Library

expect(
  screen.getByLabelText(
    'Удалить сообщение'
  )
).toBeInTheDocument();

Проверка fallback-механизмов

Полезно тестировать отсутствие переводов.

<IntlProvider
  locale="ru"
  messages={{}}
>

Если отображаются message ID:

aria.delete.button

значит fallback настроен неправильно.


Интеграция с ESLint

Плагин FormatJS помогает контролировать корректность сообщений.

Пример правила:

{
  "formatjs/enforce-id": "error"
}

Это особенно важно для accessibility-сообщений, поскольку случайное изменение id ломает локализацию ARIA.


Использование TypeScript

Типизация сообщений

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'
});

Accessibility и форматирование дат

ARIA может содержать локализованные даты.

const label = intl.formatDate(date, {
  day: 'numeric',
  month: 'long',
  year: 'numeric'
});
<button aria-label={label} />

Для русского языка:

29 мая 2026 г.

Форматирование чисел внутри ARIA

const label = intl.formatNumber(price, {
  style: 'currency',
  currency: 'RUB'
});

Результат:

1 500,00 ₽

Такой текст корректно озвучивается скринридерами.


Перевод live-region сообщений

aria-live

<div aria-live="polite">
  {
    intl.formatMessage({
      id: 'upload.complete',
      defaultMessage:
        'File uploaded successfully'
    })
  }
</div>

Скринридер озвучит сообщение на текущем языке интерфейса.


Accessibility в сложных компонентах

Таблицы

<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'
  })}
>

Наиболее распространённые ошибки

Использование текста интерфейса как ARIA-метки

<button aria-label="X">
  X
</button>

Следует описывать действие:

<button aria-label="Закрыть окно">
  X
</button>

Отсутствие перевода ARIA

Частая ситуация:

<button aria-label="Delete">
  Удалить
</button>

Визуально интерфейс русский, accessibility — английский.


Слишком длинные описания

Плохо:

aria-label="
Нажмите сюда чтобы выполнить удаление
текущего элемента списка
"

Лучше:

aria-label="Удалить элемент"

Архитектура accessibility-переводов

В крупных проектах удобно выделять отдельные namespace:

aria.*
form.*
dialog.*
navigation.*

Пример:

{
  "aria.close": "Закрыть",
  "aria.search": "Поиск",
  "aria.menu.open": "Открыть меню"
}

Такой подход упрощает поддержку accessibility-слоя приложения.