Version2

Доступність та інтернаціоналізація

VoiceOver, TalkBack, accessibilityLabel, ролі, touch targets, dynamic type та рядки UA з expo-localization у React Native

Доступність та інтернаціоналізація

Відкриття: застосунок для всіх

Уявіть людину з порушенням зору, яка відкриває ваш застосунок. Вона вмикає VoiceOver (iOS) або TalkBack (Android) — і телефон починає зачитувати вміст екрана вголос. Якщо ваша кнопка «Додати» не має accessibilityLabel, VoiceOver скаже: «Зображення, кнопка». Якщо ж ви написали accessibilityLabel="Додати нову поїздку" — вона почує зрозумілий опис і зможе користуватись застосунком самостійно.

Але доступність — це не лише для людей з обмеженими можливостями. Це також:

  • Старші користувачі із збільшеним шрифтом системи.
  • Люди в галасливих місцях без можливості прочитати дрібний текст.
  • Водії, що керують застосунком голосом.
  • Розробники, що пишуть UI-тести — адже Maestro і Detox використовують accessibilityLabel для знаходження елементів.

У цій статті ми розберемо повний стек доступності React Native: від теорії роботи скрінрідерів — до практичної реалізації доступної форми. Також розберемо інтернаціоналізацію (i18n) — переклад рядків інтерфейсу на кілька мов, починаючи з UA.

Що потрібно знати заздалегідь: ця стаття передбачає знайомство з основними компонентами React Native (View, Text, Pressable, TextInput) і базовими хуками React.

Як працюють скрінрідери

VoiceOver (iOS) і TalkBack (Android)

Скрінрідер — це системний сервіс, який «читає» інтерфейс застосунку і надає аудіо і тактильний зворотний зв'язок. Він не бачить пікселі — він читає дерево доступності (Accessibility Tree).

Loading diagram...
@startuml
skinparam style plain
skinparam defaultFontSize 12

rectangle "React Native App" {
  component [View (container)] as ROOT
  component [Text \"Подорожі\""] as H1
  component [Pressable \"+ Додати\""] as BTN
  component [FlatList\n[TripCard, TripCard...]] as LIST
}

rectangle "Accessibility Tree" {
  component [header: \"Подорожі\""] as AT_H1
  component [button: \"Додати нову поїздку\"\nhint: \"Двічі торкніться для додавання\""] as AT_BTN
  component [list: \"Список поїздок, 3 елементи\""] as AT_LIST
  component [item 1: \"Карпати 2024, 5 жовтня\""] as AT_I1
  component [item 2: \"Одеса, 12 серпня\""] as AT_I2
}

rectangle "Скрінрідер" {
  component [VoiceOver / TalkBack] as SR
}

ROOT --> AT_H1
H1 --> AT_H1
BTN --> AT_BTN
LIST --> AT_LIST
AT_LIST --> AT_I1
AT_LIST --> AT_I2

AT_H1 --> SR : зачитує
AT_BTN --> SR : зачитує
AT_LIST --> SR : зачитує
SR --> [Синтезатор мовлення] : аудіо
SR --> [Taptic Engine] : тактильний відгук
@enduml

Ключова думка: скрінрідер не бачить як виглядає кнопка — він читає метадані які ви надаєте через props доступності.

Жести скрінрідера

При увімкненому VoiceOver/TalkBack стандартні жести змінюють поведінку:

ЖестVoiceOver (iOS)TalkBack (Android)
Свайп вправо/влівоПерейти до наступного/попереднього елементаАналогічно
Подвійний тапАктивувати елемент (аналог onPress)Аналогічно
Три пальці вгору/внизСкролАналогічно
Тап і утриманняКонтекстне менюДовгий тап
Два пальці вгоруЧитати з поточного місця

Це означає: звичайний тап більше не спрацьовує при увімкненому скрінрідері. onPress спрацьовує лише від подвійного тапу (VoiceOver) або від активації (TalkBack). Pressable і TouchableOpacity обробляють це автоматично — тому важливо їх використовувати.

Як тестувати

Увімкнути VoiceOver на iOS

Налаштування → Доступність → VoiceOver → Увімкнути. Або: тричі натиснути кнопку Side/Home. У симуляторі: Hardware → Accessibility Inspector.

Увімкнути TalkBack на Android

Налаштування → Доступність → TalkBack → Увімкнути. В емуляторі: Extended Controls → Accessibility.

Accessibility Inspector (Xcode)

Найзручніший інструмент для iOS розробки: Xcode → Open Developer Tool → Accessibility Inspector. Дозволяє переглядати Accessibility Tree без фізичного телефону і без увімкнення VoiceOver.

React Native Accessibility Inspector

У React Native DevTools (Metro) є вбудований Accessibility Inspector — показує accessibility props кожного компонента в дереві.


Основні props доступності

accessible: позначити елемент як доступний

accessible={true} означає, що елемент є єдиним вузлом в accessibility tree — всі дочірні елементи об'єднуються в один. Для View за замовчуванням false, для Pressable і TextInputtrue.

// Картка як єдиний доступний елемент:
<View accessible={true} accessibilityLabel="Поїздка до Карпат, 5 жовтня">
  <Image source={...} />
  <Text>Карпати</Text>
  <Text>5 жовтня</Text>
</View>
// VoiceOver зачитає: "Поїздка до Карпат, 5 жовтня"
// а не окремо кожен Text

accessibilityLabel: що зачитає скрінрідер

Найважливіший prop доступності. Якщо не вказаний — скрінрідер намагається вгадати за Text дочірніми елементами. Для іконок, зображень і кнопок без тексту — обов'язковий:

// ❌ Погано: скрінрідер скаже "Кнопка" або нічого
<Pressable onPress={handleAdd}>
  <Icon name="plus" />
</Pressable>

// ✅ Добре: зрозумілий опис дії
<Pressable
  onPress={handleAdd}
  accessibilityLabel="Додати нову поїздку"
>
  <Icon name="plus" />
</Pressable>

Правила для гарного accessibilityLabel:

  • Описуйте що робить елемент, а не як він виглядає.
  • Не включайте слово «кнопка» — роль VoiceOver додає сам.
  • Для динамічних даних — включайте контекст: "Поїздка Карпати, 5 жовтня, 3 місця".
  • Мова label повинна відповідати мові інтерфейсу.

accessibilityHint: додаткова підказка

accessibilityHint зачитується після accessibilityLabel і описує що станеться при активації. Це необов'язкова підказка — VoiceOver показує її лише якщо користувач налаштував «Читати підказки»:

<Pressable
  accessibilityLabel="Видалити поїздку"
  accessibilityHint="Двічі торкніться, щоб безповоротно видалити поїздку"
  onPress={handleDelete}
>
  <Icon name="trash" />
</Pressable>
Не дублюйте в accessibilityHint те, що вже є в accessibilityLabel. Hint — це додаткова, уточнювальна інформація про наслідки дії. Особливо корисний для деструктивних або неочевидних операцій.

accessibilityRole: семантичні ролі

accessibilityRole повідомляє скрінрідеру яку роль виконує елемент у інтерфейсі. VoiceOver автоматично додає відповідне оголошення: «кнопка», «заголовок», «посилання» тощо.

'none'
role
Нейтральна роль. Для декоративних елементів без семантики.
'button'
role
Інтерактивна кнопка. VoiceOver: «label, кнопка». TalkBack: «Двічі торкніться для активації».
'link'
role
Посилання (навігація на інший екран або URL). VoiceOver: «label, посилання».
'header'
role
Заголовок секції. Дозволяє скрінрідеру навігувати між заголовками (жестом на iOS). Аналог <h1><h6> у вебі.
'search'
role
Поле пошуку. VoiceOver: «Пошук, текстове поле».
'image'
role
Зображення. Якщо декоративне — додайте accessibilityElementsHidden={true} або accessible={false}.
'imagebutton'
role
Кнопка із зображенням. Комбінований варіант.
'checkbox'
role
Прапорець. Використовується разом з accessibilityState={{ checked }}.
'radio'
role
Радіо-кнопка. Зазвичай у групі.
'switch'
role
Перемикач вкл/викл. Разом з accessibilityState={{ checked }}.
'tab'
role
Вкладка у Tab Bar. Разом з accessibilityState={{ selected }}.
'progressbar'
role
Індикатор прогресу. Разом з accessibilityValue={{ min: 0, max: 100, now: 45 }}.
'spinbutton'
role
Spinner або числовий input з кроками. З accessibilityValue.
'summary'
role
Елемент-резюме, що показується при запуску. На iOS — перший елемент, що озвучується.
'alert'
role
Повідомлення про помилку або попередження. Автоматично озвучується при появі.

Приклад: семантично правильна картка

function TripCard({ trip, onPress, onDelete }) {
  return (
    <Pressable
      accessible={true}
      accessibilityRole="button"
      accessibilityLabel={`Поїздка ${trip.name}, ${trip.date}`}
      accessibilityHint="Двічі торкніться для перегляду деталей"
      onPress={onPress}
      style={styles.card}
    >
      {/* Зображення — декоративне, ховаємо від скрінрідера */}
      <Image
        source={trip.image}
        style={styles.image}
        accessible={false}
        accessibilityElementsHidden={true}
      />

      {/* Заголовок поїздки */}
      <View>
        <Text
          style={styles.name}
          accessibilityRole="header"
        >
          {trip.name}
        </Text>
        <Text style={styles.date}>{trip.date}</Text>
      </View>

      {/* Кнопка видалення */}
      <Pressable
        accessibilityRole="button"
        accessibilityLabel={`Видалити поїздку ${trip.name}`}
        accessibilityHint="Дія незворотна"
        onPress={onDelete}
        hitSlop={12}
      >
        <Icon name="trash" />
      </Pressable>
    </Pressable>
  );
}

accessibilityState: стан елемента

accessibilityState — об'єкт з булевими полями, що описують поточний стан інтерактивного елемента:

disabled
boolean
Елемент недоступний для взаємодії. VoiceOver додає: «недоступно». При disabled: true скрінрідер все одно зачитує елемент, але не дозволяє активувати. Використовуйте разом з Pressable disabled={true}.
selected
boolean
Елемент вибраний (вкладка, пункт списку, чіп). VoiceOver: «вибрано».
checked
boolean | 'mixed'
Прапорець увімкнений/вимкнений. 'mixed' — проміжний стан (тільки iOS). VoiceOver: «увімкнено» / «вимкнено».
busy
boolean
Елемент завантажується (skeleton, spinner). VoiceOver: «зайнятий».
expanded
boolean
Розкладний елемент (accordion, dropdown). VoiceOver: «розгорнуто» / «згорнуто».

Приклад: кастомний прапорець

function AccessibleCheckbox({ label, checked, onToggle }) {
  return (
    <Pressable
      accessible={true}
      accessibilityRole="checkbox"
      accessibilityLabel={label}
      accessibilityState={{ checked }}
      onPress={onToggle}
      style={styles.row}
    >
      <View style={[styles.box, checked && styles.boxChecked]}>
        {checked && <Icon name="check" color="white" size={14} />}
      </View>
      <Text style={styles.label}>{label}</Text>
    </Pressable>
  );
}
// VoiceOver при checked=true: "Отримувати сповіщення, прапорець, увімкнено"
// VoiceOver при checked=false: "Отримувати сповіщення, прапорець, вимкнено"

Приклад: кастомний Switch

function AccessibleSwitch({ label, value, onValueChange }) {
  return (
    <View style={styles.row}>
      <Text style={styles.label}>{label}</Text>
      <Pressable
        accessibilityRole="switch"
        accessibilityLabel={label}
        accessibilityState={{ checked: value }}
        onPress={() => onValueChange(!value)}
      >
        <View style={[styles.track, value && styles.trackOn]}>
          <View style={[styles.thumb, value && styles.thumbOn]} />
        </View>
      </Pressable>
    </View>
  );
}

accessibilityValue: числові значення

Для елементів із числовим значенням (прогрес, слайдер, лічильник) — accessibilityValue:

min
number
Мінімальне можливе значення.
max
number
Максимальне можливе значення.
now
number
Поточне значення.
text
string
Текстовий опис значення, якщо числовий формат недостатньо зрозумілий. Перекриває числові поля для озвучення.
// Progress bar:
<View
  accessibilityRole="progressbar"
  accessibilityLabel="Завантаження поїздки"
  accessibilityValue={{ min: 0, max: 100, now: progress }}
  style={styles.progressBar}
>
  <View style={[styles.fill, { width: `${progress}%` }]} />
</View>
// VoiceOver: "Завантаження поїздки, індикатор прогресу, 45%"

// Рейтинг:
<View
  accessible={true}
  accessibilityRole="image"
  accessibilityLabel="Рейтинг готелю"
  accessibilityValue={{ text: `${rating} зірок з 5` }}
>
  <Stars rating={rating} />
</View>
// VoiceOver: "Рейтинг готелю, 4.2 зірок з 5"

Приховування від скрінрідера

accessibilityElementsHidden

Ховає компонент і всіх нащадків від скрінрідера. Ідеально для декоративних зображень, фонових елементів або контенту, що вже представлений в іншій формі:

// Декоративна іконка поруч з текстом
<View style={styles.row}>
  <Icon
    name="star"
    accessibilityElementsHidden={true} // ховаємо іконку
  />
  <Text>Улюблені місця</Text>
</View>
// VoiceOver прочитає лише "Улюблені місця"

importantForAccessibility (Android)

Android-еквівалент для контролю над accessibility tree:

// 'auto' — система вирішує (дефолт)
// 'yes' — включити (навіть якщо система б проігнорувала)
// 'no' — виключити цей елемент (але нащадки залишаються)
// 'no-hide-descendants' — виключити і всіх нащадків

<View importantForAccessibility="no-hide-descendants">
  {/* Весь цей View і його діти ігноруються TalkBack */}
  <DecorativeAnimation />
</View>
На iOS використовуйте accessibilityElementsHidden. На Android — importantForAccessibility. Для крос-платформного коду — застосовуйте обидва одночасно на різних платформах через Platform.select.

Touch Targets: зони торкання

Мінімальний розмір

Одна з найпоширеніших проблем доступності — занадто маленькі зони торкання. Людині з тремором рук або великими пальцями складно потрапити у кнопку 24×24 пікселі.

Стандарти:

  • Apple HIG (iOS): мінімум 44×44 pt (logical pixels)
  • Material Design (Android): мінімум 48×48 dp
  • WCAG 2.5.5 (Target Size): мінімум 44×44 CSS pixels (рівень AAA)
  • WCAG 2.5.8 (Target Size Minimum, рівень AA, WCAG 2.2): мінімум 24×24 CSS pixels

На практиці — прагніть до 44×44 pt для будь-якого інтерактивного елемента.

hitSlop: розширення зони без зміни розміру

hitSlop розширює зону реакції на торкання без зміни візуального розміру:

// Маленька іконка — велика зона торкання:
<Pressable
  hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
  onPress={handleClose}
  style={{ width: 20, height: 20 }} // 20pt візуально
>
  {/* Але зона торкання — 44×44pt */}
  <Icon name="x" size={20} />
</Pressable>

// Або коротко — однакові відступи з усіх боків:
<Pressable hitSlop={12} onPress={handleBack}>
  <Icon name="chevron-left" size={22} />
</Pressable>
hitSlop — найкраще рішення для іконок у toolbar, tab bar і header. Ви не змінюєте layout — лише збільшуєте «невидиму» зону реакції навколо елемента.

Перевірка розмірів автоматично

У процесі розробки корисно перевіряти мінімальні розміри:

// Утиліта для дев-режиму:
function MinTouchTarget({ children, style, ...props }) {
  const handleLayout = ({ nativeEvent: { layout } }) => {
    if (__DEV__) {
      const { width, height } = layout;
      if (width < 44 || height < 44) {
        console.warn(
          `Touch target занадто маленький: ${width.toFixed(0)}×${height.toFixed(0)}pt. Мінімум 44×44pt`
        );
      }
    }
  };

  return (
    <Pressable style={style} onLayout={handleLayout} {...props}>
      {children}
    </Pressable>
  );
}

AccessibilityInfo API

AccessibilityInfo — статичний API для програмного доступу до стану скрінрідера і оголошення повідомлень:

Перевірка стану скрінрідера

import { AccessibilityInfo } from 'react-native';

// Асинхронна перевірка:
const isEnabled = await AccessibilityInfo.isScreenReaderEnabled();

// Або через хук:
import { useState, useEffect } from 'react';

function useScreenReader() {
  const [isEnabled, setIsEnabled] = useState(false);

  useEffect(() => {
    // Початковий стан
    AccessibilityInfo.isScreenReaderEnabled().then(setIsEnabled);

    // Слухач змін
    const subscription = AccessibilityInfo.addEventListener(
      'screenReaderChanged',
      setIsEnabled
    );

    return () => subscription.remove();
  }, []);

  return isEnabled;
}

announceForAccessibility: програмне оголошення

Надсилає повідомлення скрінрідеру без зміни фокусу. Ідеально для оповіщення про динамічні зміни:

// Після успішного збереження:
AccessibilityInfo.announceForAccessibility('Поїздку збережено успішно');

// Після помилки:
AccessibilityInfo.announceForAccessibility('Помилка: введіть назву поїздки');

// При оновленні лічильника:
AccessibilityInfo.announceForAccessibility(`Додано ${count} елементів до списку`);
announceForAccessibility на Android може перебивати поточне читання. Викликайте його лише після значущих подій (збереження, помилка, завантаження), а не при кожній зміні стану.

setAccessibilityFocus: перемістити фокус

При відкритті модального вікна або появі важливого елемента — корисно автоматично перемістити фокус скрінрідера:

import { AccessibilityInfo, findNodeHandle, useRef } from 'react';

function Modal({ title, onClose }) {
  const titleRef = useRef(null);

  useEffect(() => {
    // Коли Modal змонтовано — переміщуємо фокус на заголовок
    const timer = setTimeout(() => {
      if (titleRef.current) {
        const node = findNodeHandle(titleRef.current);
        if (node) AccessibilityInfo.setAccessibilityFocus(node);
      }
    }, 100); // невелика затримка для анімації

    return () => clearTimeout(timer);
  }, []);

  return (
    <View accessible={true} accessibilityViewIsModal={true}>
      <Text ref={titleRef} accessibilityRole="header">{title}</Text>
      <Pressable accessibilityLabel="Закрити вікно" onPress={onClose}>
        <Icon name="x" />
      </Pressable>
    </View>
  );
}

accessibilityViewIsModal={true} — повідомляє VoiceOver, що це модальне вікно, і фокус не повинен виходити за його межі.

Всі методи AccessibilityInfo

isScreenReaderEnabled()
Promise<boolean>
Чи увімкнений скрінрідер (VoiceOver/TalkBack).
isBoldTextEnabled()
Promise<boolean> (iOS)
Чи увімкнений «Жирний текст» у системних налаштуваннях.
isReduceMotionEnabled()
Promise<boolean>
Чи увімкнений «Зменшити рух» (Reduce Motion). Альтернатива useReducedMotion з Reanimated.
isReduceTransparencyEnabled()
Promise<boolean> (iOS)
Чи увімкнена «Зменшити прозорість» — впливає на blur-ефекти і прозорі фони.
isGrayscaleEnabled()
Promise<boolean> (iOS)
Чи увімкнений «Відтінки сірого» — деякі користувачі з дальтонізмом вмикають це.
announceForAccessibility(message)
void
Програмне оголошення тексту через скрінрідер.
setAccessibilityFocus(nodeHandle)
void
Переміщення фокусу скрінрідера на вказаний нативний вузол.
addEventListener(eventName, handler)
Subscription
Підписка на зміни стану: 'screenReaderChanged', 'reduceMotionChanged', 'boldTextChanged' тощо.

Dynamic Type: масштабування шрифту

Що таке Dynamic Type

Dynamic Type (iOS) і Font Scale (Android) — це системне налаштування, що дозволяє користувачу збільшити або зменшити розмір тексту по всій ОС. Це критично важливо для людей з порушеннями зору.

iOS підтримує 12 рівнів масштабу (XS → XXXL + Accessibility sizes від 310% до 430%). Android — від 85% до 200% і вище на деяких пристроях.

Якщо ваш застосунок ігнорує масштаб шрифту — тексти залишаються дрібними навіть при максимальному системному налаштуванні.

allowFontScaling

Всі Text компоненти за замовчуванням поважають системний масштаб шрифту (allowFontScaling={true}). Це правильна поведінка — не вимикайте без необхідності:

// ✅ Правильно — масштаб враховується (дефолт):
<Text style={{ fontSize: 16 }}>Назва поїздки</Text>

// ⚠️ Вимкнути тільки якщо є вагома причина:
<Text style={{ fontSize: 10 }} allowFontScaling={false}>
  © 2024
</Text>

maxFontSizeMultiplier: обмеження масштабу

Для деяких елементів — наприклад, іконок-емодзі або елементів UI де текст має не перекривати layout — можна обмежити максимальний множник:

<Text
  style={{ fontSize: 16 }}
  maxFontSizeMultiplier={1.5} // максимум 150% від базового розміру
>
  {trip.name}
</Text>

// TabBar label — не даємо рости більш ніж вдвічі:
<Text style={{ fontSize: 12 }} maxFontSizeMultiplier={2.0}>
  Поїздки
</Text>
allowFontScaling
boolean (дефолт: true)
Чи враховувати системний масштаб шрифту. Не вимикайте без вагомої причини — це погіршує доступність.
maxFontSizeMultiplier
number
Максимальний множник відносно fontSize. 1.5 = максимум 150%. 0 = без обмежень (аналог allowFontScaling={true}).
dynamicTypeRamp
string (iOS 15+)
iOS dynamic type ramp: 'caption1', 'caption2', 'footnote', 'subheadline', 'callout', 'body', 'headline', 'title1', 'title2', 'title3', 'largeTitle'. Замість fontSize відповідає iOS системним стилям тексту.

Адаптивні layouts для великих шрифтів

При великих шрифтах layout може ламатись — текст виходить за межі, кнопки стають занадто тісними. Використовуйте гнучкий layout:

// ❌ Фіксована висота — поламається при великому шрифті:
<View style={{ height: 44, flexDirection: 'row', alignItems: 'center' }}>
  <Text>Назва поїздки</Text>
  <Icon name="chevron" />
</View>

// ✅ Гнучка висота — адаптується:
<View style={{ minHeight: 44, flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap' }}>
  <Text style={{ flex: 1 }}>Назва поїздки</Text>
  <Icon name="chevron" />
</View>

Живий приклад: dynamic font scaling

TSXApp.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Колірний контраст

Стандарти WCAG

WCAG 2.1 встановлює мінімальні вимоги до контрасту між текстом і фоном:

РівеньЗвичайний текстВеликий текст (≥18pt або ≥14pt bold)
AA (мінімум)4.5:13:1
AAA (підвищений)7:14.5:1

Для вимірювання контрасту використовуйте інструменти:

Приклади поганого і доброго контрасту

// ❌ Погано: сірий текст на білому (#94A3B8 на #FFFFFF = 2.5:1)
<Text style={{ color: '#94A3B8', backgroundColor: 'white' }}>
  Опис поїздки
</Text>

// ✅ Добре: темний текст (#475569 на #FFFFFF = 4.7:1)
<Text style={{ color: '#475569', backgroundColor: 'white' }}>
  Опис поїздки
</Text>

// ✅ Відмінно: (#1E293B на #FFFFFF = 16.8:1)
<Text style={{ color: '#1E293B', backgroundColor: 'white' }}>
  Назва поїздки
</Text>
Найпоширеніша помилка — placeholder тексти і secondary labels у кольорі #94A3B8 (slate-400) або #9CA3AF (gray-400) на білому фоні. Контраст ~2.5:1 — це вдвічі нижче мінімуму WCAG AA. Використовуйте #64748B (slate-500, контраст ~4.7:1) або темніше.

Дальтонізм та Not Relying on Color Alone

~8% чоловіків і ~0.5% жінок мають певну форму дальтонізму. Правило: ніколи не використовуйте колір як єдиний спосіб передачі інформації:

// ❌ Погано: помилка і успіх відрізняються лише кольором
<Text style={{ color: isError ? 'red' : 'green' }}>
  {message}
</Text>

// ✅ Добре: колір + іконка + текст-індикатор
<View style={styles.messageRow}>
  <Icon name={isError ? 'x-circle' : 'check-circle'} color={isError ? '#EF4444' : '#10B981'} />
  <Text style={[styles.message, { color: isError ? '#DC2626' : '#059669' }]}>
    {isError ? `Помилка: ${message}` : `Успішно: ${message}`}
  </Text>
</View>

Інтернаціоналізація (i18n): рядки UA

Навіщо i18n з першого дня

Навіть якщо ви плануєте лише українську мову — правильна i18n-архітектура з першого рядка коду дає:

  • Можливість легко додати англійську, польську чи іншу мову пізніше.
  • Централізоване управління рядками — зміна тексту в одному місці.
  • Зручний процес для перекладачів (без зміни коду).
  • Автоматичну підтримку правил числівників (1 поїздка, 2 поїздки, 5 поїздок).

expo-localization

expo-localization надає інформацію про мову, регіон і локаль пристрою:

npx expo install expo-localization i18next react-i18next
import * as Localization from 'expo-localization';

// Мова пристрою:
console.log(Localization.locale);     // 'uk-UA', 'en-US', 'pl-PL'
console.log(Localization.locales);    // ['uk-UA', 'en-US'] — пріоритетний список

// Регіон і часовий пояс:
console.log(Localization.region);     // 'UA'
console.log(Localization.timezone);   // 'Europe/Kyiv'

// RTL (право-ліво, для арабської, іврит):
console.log(Localization.isRTL);      // false для UA/EN

Базова i18n з i18next

Найпопулярніший підхід — i18next з react-i18next:

// i18n/config.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as Localization from 'expo-localization';

const resources = {
  uk: {
    translation: {
      // Загальне
      'common.save': 'Зберегти',
      'common.cancel': 'Скасувати',
      'common.delete': 'Видалити',
      'common.loading': 'Завантаження...',
      'common.error': 'Сталася помилка',

      // Поїздки
      'trips.title': 'Мої подорожі',
      'trips.add': 'Додати поїздку',
      'trips.empty': 'Ще немає поїздок',
      'trips.count_one': '{{count}} поїздка',
      'trips.count_few': '{{count}} поїздки',
      'trips.count_many': '{{count}} поїздок',
      'trips.delete_confirm': 'Видалити поїздку «{{name}}»?',

      // Форма
      'form.name': 'Назва',
      'form.name_placeholder': 'Наприклад: Карпати 2024',
      'form.date': 'Дата',
      'form.required': 'Обов\'язкове поле',
    },
  },
  en: {
    translation: {
      'common.save': 'Save',
      'common.cancel': 'Cancel',
      'common.delete': 'Delete',
      'common.loading': 'Loading...',
      'common.error': 'An error occurred',

      'trips.title': 'My Trips',
      'trips.add': 'Add Trip',
      'trips.empty': 'No trips yet',
      'trips.count_one': '{{count}} trip',
      'trips.count_other': '{{count}} trips',
      'trips.delete_confirm': 'Delete trip "{{name}}"?',

      'form.name': 'Name',
      'form.name_placeholder': 'e.g. Carpathians 2024',
      'form.date': 'Date',
      'form.required': 'Required field',
    },
  },
};

// Визначаємо мову за системними налаштуваннями:
const deviceLocale = Localization.locale.split('-')[0]; // 'uk', 'en', 'pl'
const supportedLocales = ['uk', 'en'];
const lng = supportedLocales.includes(deviceLocale) ? deviceLocale : 'uk';

i18n.use(initReactI18next).init({
  resources,
  lng,
  fallbackLng: 'uk',
  interpolation: { escapeValue: false }, // React вже екранує
});

export default i18n;

Ініціалізація у _layout.tsx

// app/_layout.tsx
import '@/i18n/config'; // ініціалізуємо i18n одразу
import { I18nextProvider } from 'react-i18next';
import i18n from '@/i18n/config';

export default function RootLayout() {
  return (
    <I18nextProvider i18n={i18n}>
      {/* решта layout */}
    </I18nextProvider>
  );
}

Використання у компонентах

import { useTranslation } from 'react-i18next';

export function TripsScreen() {
  const { t } = useTranslation();
  const [trips, setTrips] = useState([...]);

  return (
    <View>
      {/* Простий рядок */}
      <Text accessibilityRole="header">{t('trips.title')}</Text>

      {/* З параметром */}
      <Text>{t('trips.count', { count: trips.length })}</Text>
      // → "3 поїздки" (UA) або "3 trips" (EN)

      {/* Підтвердження з назвою */}
      <Text>{t('trips.delete_confirm', { name: selectedTrip.name })}</Text>
      // → "Видалити поїздку «Карпати 2024»?"

      {/* Порожній стан */}
      {trips.length === 0 && (
        <Text>{t('trips.empty')}</Text>
      )}
    </View>
  );
}

Правило числівників в українській мові

Українська мова має складніші правила числівників ніж англійська:

КількістьФормаПриклад
1, 21, 31...one«1 поїздка»
2, 3, 4, 22, 23...few«3 поїздки»
5–20, 25–30...many«7 поїздок»
0, дробиother«0 поїздок»

i18next з i18next-icu або вбудованою підтримкою plural forms автоматично обирає правильну форму:

// i18next автоматично обирає форму за `count`:
t('trips.count', { count: 1 })   // → "1 поїздка"
t('trips.count', { count: 3 })   // → "3 поїздки"
t('trips.count', { count: 10 })  // → "10 поїздок"

Міні-проєкт: «A11y pass» — доступна форма

Що будуємо

Один екран форми додавання поїздки з повними accessibility props:

  1. Заголовок з accessibilityRole="header".
  2. TextInput-и з accessibilityLabel, accessibilityHint, правильним keyboardType.
  3. Dropdown-picker зі accessibilityRole="combobox" і accessibilityState={{ expanded }}.
  4. Кнопки з accessibilityLabel і hitSlop={12}.
  5. Inline-помилки з accessibilityRole="alert" — автоматично озвучуються.
  6. Індикатор прогресу відправлення з accessibilityValue.
  7. Підтримка Dynamic Type (maxFontSizeMultiplier={1.5}).
  8. Рядки через t() з i18next.

Живий preview: форма з a11y

TSXApp.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

A11y Checklist для кожного екрана

Використовуйте як чеклист при ревью будь-якого нового екрана:


Підключення до Nomad: feat: a11y labels and reduced motion

У Nomad accessibility і i18n підвищують якість продукту для широкої аудиторії:

Всі екрани

  • Заголовки з accessibilityRole="header"
  • Картки поїздок як єдиний accessible={true} елемент
  • announceForAccessibility після CRUD операцій

Форми

  • accessibilityLabel + accessibilityHint на всіх полях
  • accessibilityRole="alert" для помилок валідації
  • accessibilityRequired на обов'язкових полях

Карта

  • Маркери з accessibilityLabel="[назва місця], [адреса]"
  • FAB кнопка «+» з accessibilityLabel="Додати місце на карті"

i18n + Reduced Motion

  • Всі рядки через t() — готовність до EN перекладу
  • ReduceMotion.System у всіх Reanimated анімаціях (розділ 24)
  • useReducedMotion() для вимкнення parallax і складних анімацій

Поширені помилки


Підсумок

Скрінрідери та дерево доступності

  • VoiceOver (iOS) і TalkBack (Android) читають Accessibility Tree
  • accessible, accessibilityLabel, accessibilityHint
  • accessibilityRole — семантика елемента
  • accessibilityStatedisabled, selected, checked, expanded

Touch Targets та Focus

  • Мінімум 44×44pt (iOS) / 48×48dp (Android)
  • hitSlop для розширення без зміни layout
  • AccessibilityInfo.setAccessibilityFocus при появі модалок
  • AccessibilityInfo.announceForAccessibility для динамічних змін

Dynamic Type та контраст

  • allowFontScaling={true} — не вимикати!
  • maxFontSizeMultiplier для обмеження зросту
  • minHeight замість height для гнучких layouts
  • Мінімум 4.5:1 для звичайного тексту (WCAG AA)

i18n: рядки UA

  • expo-localization для мови пристрою
  • i18next + react-i18next — стандарт галузі
  • Plural forms для UK: one/few/many
  • Всі рядки через t() з першого дня
Copyright © 2026