Version2

Анімації з Reanimated

Shared values, worklets, springs, layout animations та reduced motion у React Native Reanimated 3 — від теорії до плавного UI

Анімації з Reanimated

Відкриття: чому анімації — це не «прикраса»

Відкрийте будь-який застосунок від Apple — Messages, Maps, Photos. Помітьте: кожна дія супроводжується плавним рухом. Кнопки «відчуваються» при натисканні, картки розкриваються природно, елементи з'являються і зникають з вагою. Це не випадковість і не зайвина розкіш — це функціональний елемент інтерфейсу.

Дослідження UX показують, що правильні анімації:

  • Пояснюють ієрархію — звідки з'явився екран і куди пішов.
  • Зменшують когнітивне навантаження — мозок сприймає безперервний рух легше, ніж миттєві зміни.
  • Дають підтвердження — «так, ваш тап зареєстровано».
  • Приховують затримки — skeleton-анімація під час завантаження робить очікування терпимим.

Але є нюанс: погано написані анімації шкідливіші за їх відсутність. Якщо анімація «підстрибує», запізнюється на 200 мс або «смикається» при скролі — користувач відчуває це фізично і відносить це до «дешевого» застосунку.

У цій статті ми розберемо React Native Reanimated 3 — бібліотеку, яка переносить анімації на нативний UI потік і дає можливість створювати анімації рівня нативних застосунків.

Про preview-приклади в цій статті:react-native-preview виконує код через react-native-web і підтримує лише стандартну бібліотеку react-native. Тому в інтерактивних прикладах ми використовуємо Animated API — стандартний інструмент React Native. Паралельно я показуватиму Reanimated-еквівалент у звичайних блоках коду. Це також хороша педагогіка: зрозумівши Animated, ви побачите, що саме Reanimated покращує і чому.

Анімаційна модель React Native: JS Thread vs UI Thread

Чому звичайний Animated іноді «смикається»

React Native Animated API існує з самого початку бібліотеки. Він дозволяє анімувати значення і прив'язувати їх до стилів компонентів. Але є суттєве обмеження:

Стандартний Animated (без useNativeDriver):

JS Thread: [обчислення] → [Bridge] → [UI Thread: оновлення стилю]
JS Thread: [обчислення] → [Bridge] → [UI Thread: оновлення стилю]
...

При 60 fps це відбувається 60 разів/сек.
Якщо JS Thread зайнятий (рендеринг, мережа) — кадри пропускаються.

useNativeDriver: true вирішує частину проблеми: анімація передається на UI Thread і більше не залежить від JS. Але useNativeDriver підтримує лише transform і opacity — ніяких width, height, margin, кольорів.

Як Reanimated виходить за ці обмеження

React Native Reanimated переносить весь код анімації — включно з логікою — на нативний UI потік через механізм worklets:

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

rectangle "JavaScript Thread" {
  component [React компонент\n(логіка, стейт, рендер)] as REACT
  component [useSharedValue()\ninitialValue] as SHARED_DEF
}

rectangle "UI Thread (Reanimated Runtime)" {
  component [SharedValue\n(реальна змінна)] as SHARED
  component [Worklet\n(JS функція на C++)] as WORKLET
  component [useAnimatedStyle\n→ CSS оновлення] as ANIM_STYLE
}

rectangle "Native Render" {
  component [Нативний View] as VIEW
}

REACT --> SHARED_DEF : визначає
SHARED_DEF --> SHARED : ініціалізує
SHARED --> WORKLET : викликає
WORKLET --> ANIM_STYLE : обчислює стилі
ANIM_STYLE --> VIEW : без bridge!
REACT --> SHARED : .value = новеValue (асинхронно)
@enduml

Ключова ідея: worklet — це JavaScript функція, яку Reanimated компілює і виконує безпосередньо на C++ рантаймі UI потоку. Виклик не потребує серіалізації через міст. Саме тому Reanimated анімує будь-які CSS-властивості (width, height, backgroundColor, borderRadius) з тією ж продуктивністю що й transform/opacity.

Worklet: що це і як позначити

Функція стає worklet-ом через директиву 'worklet' на першому рядку:

function myWorklet(value: number): number {
  'worklet';
  return value * 2; // виконується на UI Thread
}

Усі функції, передані у useAnimatedStyle, useAnimatedScrollHandler, useAnimatedGestureHandler — автоматично компілюються як worklets. Явне 'worklet' потрібне лише для власних утиліт.

Звичайні JS функції не можна викликати з worklet-а без runOnJS(). Це включає console.log, setState, API виклики. Якщо потрібно вийти в JS Thread — використовуйте runOnJS(myFn)(args).

Встановлення та налаштування

npx expo install react-native-reanimated

Обов'язково: додати плагін у babel.config.js:

// babel.config.js
module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: ['react-native-reanimated/plugin'], // ← завжди ОСТАННІМ
  };
};

Після зміни babel.config.jsобов'язковий перезапуск Metro з очищенням кешу:

npx expo start --clear
Плагін react-native-reanimated/pluginповинен бути останнім у масиві plugins. Порушення цього правила — найпоширеніша причина дивних помилок типу «worklet function called on wrong thread».

Основи: useSharedValue та useAnimatedStyle

useSharedValue: змінна двох потоків

useSharedValue — основний примітив Reanimated. Це змінна, яка існує одночасно у JS Thread і UI Thread:

import { useSharedValue } from 'react-native-reanimated';

const opacity = useSharedValue(1);     // початкове значення — 1
const scale = useSharedValue(1);
const offsetY = useSharedValue(0);

Читання і запис:

// З JS Thread (наприклад, у обробнику onPress):
opacity.value = 0;          // миттєво
opacity.value = withTiming(0, { duration: 300 }); // анімовано

// Читання (завжди актуальне):
console.log(opacity.value); // поточне значення
useSharedValue схожий на useRef тим, що зміна .valueне спричиняє ре-рендер. Це навмисне: анімації повинні бути ізольовані від React render-циклу.

useAnimatedStyle: зв'язок між SharedValue і стилями

useAnimatedStyle — це worklet, що перетворює shared values на об'єкт стилів. Він автоматично підписується на зміни використаних shared values:

import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated';

function FadeBox() {
  const opacity = useSharedValue(1);

  const animatedStyle = useAnimatedStyle(() => ({
    opacity: opacity.value,
    transform: [{ scale: opacity.value }],
  }));

  return (
    <Animated.View style={[styles.box, animatedStyle]}>
      <Text>Привіт!</Text>
    </Animated.View>
  );
}

Ключові правила useAnimatedStyle:

  • Тіло функції виконується на UI Thread — це worklet.
  • Не можна викликати хуки React всередині.
  • Не можна звертатись до JavaScript closure values напряму — тільки через useSharedValue.
  • Повертає стандартний об'єкт стилів.

Animated.View та інші Animated-компоненти

Щоб стилі з useAnimatedStyle застосовувались до компонента — потрібно використовувати Animated.View, Animated.Text, Animated.Image, Animated.ScrollView з Reanimated:

import Animated from 'react-native-reanimated';

// Використовуємо Animated.View замість View:
<Animated.View style={[styles.box, animatedStyle]} />

Або для кастомних компонентів:

import Animated from 'react-native-reanimated';
import { FlatList } from 'react-native';

const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);

Анімаційні функції: withTiming, withSpring і інші

Концепція анімаційних функцій

Reanimated надає набір анімаційних функцій, що повертають не число, а «рецепт» анімації. Вони передаються у .value:

opacity.value = withTiming(0);       // "анімуй до 0 за допомогою easing"
scale.value = withSpring(1.2);       // "анімуй до 1.2 за законами пружини"

withTiming: лінійна анімація з easing

withTiming анімує значення за заданий час з кривою прискорення (easing):

import { withTiming, Easing } from 'react-native-reanimated';

opacity.value = withTiming(0, {
  duration: 300,           // мілісекунди
  easing: Easing.out(Easing.cubic), // крива
});

Стандартні easing функції

Easing.linear
easing
Рівномірний рух без прискорення. Підходить для паралакс-ефектів і progress bars, але виглядає «механічно» для більшості UI елементів.
Easing.ease
easing
Стандарт CSS ease: повільний старт, швидка середина, повільний кінець. Ідеально для більшості fade-анімацій.
Easing.out(Easing.quad)
easing
Швидкий старт, повільний фініш. Виглядає природно при «кидку» елементів (вилітають швидко, гальмують). Найрекомендованіший для появи елементів.
Easing.in(Easing.quad)
easing
Повільний старт, швидкий фініш. Для зникання елементів — вони «набирають швидкість» і йдуть.
Easing.inOut(Easing.cubic)
easing
Симетрична крива: повільний старт, швидка середина, повільний кінець. Для переміщень елементів між позиціями.
Easing.bezier(x1, y1, x2, y2)
easing
Кастомна крива Безьє. Аналог CSS cubic-bezier(). Для фірмових стилів анімації.
Easing.back(1.7)
easing
Небольшой «відкат» назад перед рухом вперед. Придає фізичність, але обережно — занадто великий коефіцієнт виглядає мультяшно.
Easing.bounce
easing
Відбивання наприкінці. Для ігрових застосунків і привертання уваги. У бізнес-застосунках — обережно.

Живий приклад: withTiming

TSXApp.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

withSpring: фізика пружини

withSpring замість часу і easing використовує фізичну модель пружини. Результат — природний, органічний рух без потреби підбирати easing:

import { withSpring } from 'react-native-reanimated';

scale.value = withSpring(1.2, {
  damping: 10,      // затухання: менше → більше коливань
  stiffness: 100,   // жорсткість: більше → швидший рух
  mass: 1,          // маса: більше → повільніший і «важчий» рух
  velocity: 0,      // початкова швидкість (м/с)
  overshootClamping: false, // дозволяти перестрілювання?
  restDisplacementThreshold: 0.01,
  restSpeedThreshold: 0.01,
});
damping
number (дефолт: 10)
Коефіцієнт затухання. damping: 80 — практично без коливань (critically damped). damping: 5 — сильні коливання. Для більшості UI: 10–15.
stiffness
number (дефолт: 100)
«Жорсткість» пружини. Більше — швидший і різкіший рух. stiffness: 200 — швидко і чітко. stiffness: 50 — плавно і повільно.
mass
number (дефолт: 1)
Маса об'єкта. Більша маса = повільніший рух і більша інерція. Зазвичай залишають 1.
overshootClamping
boolean (дефолт: false)
Якщо true — пружина зупиняється точно на цільовому значенні без перестрілювання. Для числових селекторів і точних позицій.

Живий приклад: spring vs timing

TSXApp.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Композиція анімацій: withSequence, withDelay, withRepeat

withSequence: анімації по черзі

withSequence запускає кілька анімацій одну за одною:

import { withSequence, withTiming, withSpring } from 'react-native-reanimated';

// Кнопка: стиснутись → розширитись (пульсація підтвердження)
scale.value = withSequence(
  withTiming(0.9, { duration: 100 }),  // стискаємось
  withSpring(1.1),                      // вибухаємо трохи більше
  withSpring(1.0),                      // повертаємось
);

withDelay: затримка перед анімацією

import { withDelay, withTiming } from 'react-native-reanimated';

// З'являємось через 500 мс після монтування
opacity.value = withDelay(500, withTiming(1, { duration: 300 }));

withDelay зручно комбінувати з withSequence для staggered анімацій (елементи з'являються один за одним):

// Анімуємо 3 елементи по черзі з кроком 100 мс:
items.forEach((item, index) => {
  item.opacity.value = withDelay(
    index * 100,
    withTiming(1, { duration: 300 })
  );
});

withRepeat: нескінченна анімація

import { withRepeat, withTiming } from 'react-native-reanimated';

// Пульсація: fade in-out нескінченно
opacity.value = withRepeat(
  withTiming(0.3, { duration: 800 }),
  -1,      // -1 = нескінченно
  true,    // reverse = true → після кожного циклу повертається
);

Параметри withRepeat

animation
AnimatableValue
Анімація, яку повторюємо. Може бути withTiming, withSpring, або withSequence.
numberOfReps
number (дефолт: 2)
Кількість повторень. -1 — нескінченно.
reverse
boolean (дефолт: false)
Якщо true — кожний парний цикл іде у зворотному напрямку. Ідеально для пульсацій та «breathing» ефектів.
callback
(finished: boolean) => void
Викликається після завершення всіх повторень.

Живий приклад: skeleton loading

Skeleton loading — один з найкорисніших застосунків withRepeat. Показуємо «кістяк» контенту поки дані завантажуються:

TSXApp.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


useDerivedValue: обчислені анімовані значення

useDerivedValue — це computed shared value. Він автоматично перераховується при зміні залежних shared values, на UI Thread:

import { useSharedValue, useDerivedValue, withSpring } from 'react-native-reanimated';

const progress = useSharedValue(0); // 0..1

// Автоматично перераховується при зміні progress:
const opacity = useDerivedValue(() => progress.value);
const scale = useDerivedValue(() => 0.8 + progress.value * 0.2); // 0.8..1.0
const rotation = useDerivedValue(() => `${progress.value * 360}deg`);

Типові сценарії:

  • Перетворення одного значення (прогрес 0..1) на кілька похідних (opacity, scale, translateY).
  • Інтерполяція між двома кольорами.
  • Обчислення позиції з урахуванням кількох джерел.

interpolate: гнучке відображення значень

interpolate — аналог Animated.Value.interpolate, але для Reanimated shared values. Використовується всередині worklets (наприклад, useAnimatedStyle):

import { interpolate, Extrapolation } from 'react-native-reanimated';

const animatedStyle = useAnimatedStyle(() => {
  // Коли scrollY від 0 до 200 → opacity від 1 до 0
  const opacity = interpolate(
    scrollY.value,
    [0, 200],          // inputRange
    [1, 0],            // outputRange
    Extrapolation.CLAMP // що робити за межами?
  );

  // Коли scrollY від 0 до 100 → translateY від 0 до -50
  const translateY = interpolate(
    scrollY.value,
    [0, 100],
    [0, -50],
    Extrapolation.CLAMP
  );

  return { opacity, transform: [{ translateY }] };
});

Extrapolation режими

Extrapolation.CLAMP
enum
Значення «затискається» в межах outputRange. Найбезпечніший варіант — за межами inputRange значення не змінюється.
Extrapolation.EXTEND
enum
Лінійне продовження за межі. Якщо inputRange 0, 100 → outputRange 0, 50, то при input=200 output=100.
Extrapolation.IDENTITY
enum
Повертає input без змін за межами діапазону.

Живий приклад: parallax scroll header

TSXApp.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Layout Animations: анімації монтування і розмонтування

Що таке Layout Animations

Layout Animations — одна з найпотужніших особливостей Reanimated 3. Вони дозволяють анімувати:

  • Появу компонента при монтуванні (entering)
  • Зникнення при розмонтуванні (exiting)
  • Зміну положення/розміру компонента при зміні layout (layout)

І все це — одним рядком коду на будь-якому Animated.View. Жодного useEffect, жодних ручних анімацій.

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

rectangle "Reanimated Layout Animation Engine" {
  component [Entering\n(монтування)] as ENTER
  component [Exiting\n(розмонтування)] as EXIT
  component [Layout Transition\n(зміна layout)] as LAYOUT
}

rectangle "Базові класи" {
  component [FadeIn / FadeOut] as FADE
  component [SlideInLeft / SlideOutRight] as SLIDE
  component [ZoomIn / ZoomOut] as ZOOM
  component [BounceIn / BounceOut] as BOUNCE
  component [FlipIn / FlipOut] as FLIP
  component [LightSpeedIn / LightSpeedOut] as LIGHT
  component [PinwheelIn / PinwheelOut] as PIN
}

ENTER --> FADE
ENTER --> SLIDE
ENTER --> ZOOM
ENTER --> BOUNCE
EXIT --> FADE
EXIT --> SLIDE
EXIT --> ZOOM
LAYOUT --> [LinearTransition]
LAYOUT --> [CurvedTransition]
LAYOUT --> [SequencedTransition]
@enduml

entering та exiting: перший рядок коду

import Animated, { FadeIn, FadeOut, SlideInLeft } from 'react-native-reanimated';

// Компонент з'являється через FadeIn і зникає через FadeOut:
<Animated.View entering={FadeIn} exiting={FadeOut}>
  <Text>Я з'являюсь плавно!</Text>
</Animated.View>

// Картка вилітає зліва при додаванні:
<Animated.View entering={SlideInLeft.duration(300)}>
  <Card />
</Animated.View>

Таблиця всіх entering/exiting анімацій

КласЕфект
FadeIn / FadeOutПлавна поява/зникнення через opacity
SlideInLeft / SlideOutLeftВліт/виліт зліва
SlideInRight / SlideOutRightВліт/виліт справа
SlideInUp / SlideOutUpВліт/виліт зверху
SlideInDown / SlideOutDownВліт/виліт знизу
ZoomIn / ZoomOutМасштабування від центра
ZoomInUp / ZoomOutUpМасштабування з верхнього краю
BounceIn / BounceOutВідбивання при появі
BounceInLeft / BounceOutRightВідбивання зі сторони
FlipInX / FlipOutXПерегортання по горизонтальній осі
FlipInY / FlipOutYПерегортання по вертикальній осі
StretchInX / StretchOutXРозтягування по горизонталі
LightSpeedInLeftСтрімкий вліт із скосом
PinwheelIn / PinwheelOutВертушка (обертальна поява)
RollInLeft / RollOutRightКочення зі сторони

Кастомізація: duration, delay, easing

Кожен клас — це builder, що дозволяє налаштувати параметри через ланцюжок методів:

// Затриманий FadeIn тривалістю 500 мс
FadeIn.duration(500).delay(200)

// SlideInUp зі spring-фізикою
SlideInUp.springify().damping(15).stiffness(120)

// ZoomIn з кастомним easing
ZoomIn.duration(400).easing(Easing.out(Easing.cubic))

// BounceIn без ефекту відбивання (useNativeDriver safety)
BounceIn.duration(600).reduceMotion(ReduceMotion.System)

Живий приклад: staggered list появи

Кожен елемент списку з'являється зі зміщенням у часі (stagger effect):

TSXApp.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

layout: анімація переміщення при зміні позиції

layout prop — найменш очевидна, але найпотужніша анімація. Коли компонент змінює свій розмір або позицію (наприклад, інший елемент вище додається/видаляється), з layout він плавно «переповзає» на нове місце:

import Animated, { LinearTransition } from 'react-native-reanimated';

// Кожен елемент списку при видаленні сусіда — плавно займе нову позицію
{items.map((item) => (
  <Animated.View
    key={item.id}
    entering={FadeIn}
    exiting={FadeOut}
    layout={LinearTransition}  // ← магія тут
  >
    <ItemRow item={item} />
  </Animated.View>
))}

Типи Layout Transition

LinearTransition
LayoutAnimationFunction
Лінійне переміщення між поточною і цільовою позицією. Найпростіший і найуживаніший.
CurvedTransition
LayoutAnimationFunction
Рух по кривій траєкторії. Виглядає більш природно для діагональних переміщень.
FadingTransition
LayoutAnimationFunction
Елемент fade-out на старій позиції і fade-in на новій.
SequencedTransition
LayoutAnimationFunction
Спочатку анімує зміну розміру, потім позиції (або навпаки). Для складних layout-змін.
JumpingTransition
LayoutAnimationFunction
Елемент «підстрибує» при переміщенні. Ігровий ефект.

Живий приклад: фільтрований список

TSXApp.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


useAnimatedScrollHandler: реакція на скрол

Проблема звичайного onScroll

Стандартний onScroll у React Native — це подія, що передається через JavaScript міст. При scrollEventThrottle={16} (60 fps) — 60 повідомлень на секунду через асинхронний канал. Кожне з них провокує обробку у JS потоці. При складних анімаціях заголовка або parallax — це помітне навантаження.

useAnimatedScrollHandler вирішує це: колбек виконується на UI Thread, прямо де скролиться контент.

Базове використання

import Animated, { useSharedValue, useAnimatedScrollHandler } from 'react-native-reanimated';

function ParallaxScreen() {
  const scrollY = useSharedValue(0);

  const scrollHandler = useAnimatedScrollHandler((event) => {
    // Це worklet — виконується на UI Thread:
    scrollY.value = event.contentOffset.y;
  });

  const headerStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: -scrollY.value * 0.5 }],
    opacity: interpolate(scrollY.value, [0, 150], [1, 0], Extrapolation.CLAMP),
  }));

  return (
    <View>
      <Animated.View style={[styles.header, headerStyle]} />
      <Animated.ScrollView onScroll={scrollHandler} scrollEventThrottle={16}>
        {/* контент */}
      </Animated.ScrollView>
    </View>
  );
}

Повний об'єкт події scrollHandler

useAnimatedScrollHandler приймає об'єкт з кількома колбеками:

const scrollHandler = useAnimatedScrollHandler({
  onScroll: (event) => {
    scrollY.value = event.contentOffset.y;
    scrollX.value = event.contentOffset.x;
  },
  onBeginDrag: (event) => {
    // користувач почав свайп
    isDragging.value = true;
  },
  onEndDrag: (event) => {
    isDragging.value = false;
    // event.velocity.y — швидкість при відпусканні
  },
  onMomentumBegin: (event) => {
    // scroll inertia почалась
  },
  onMomentumEnd: (event) => {
    // scroll зупинився
  },
});

Живий приклад: sticky header з анімацією

TSXApp.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


cancelAnimation та runOnJS

cancelAnimation: зупинити анімацію в будь-який момент

import { cancelAnimation } from 'react-native-reanimated';

// Зупиняємо поточну анімацію shared value:
cancelAnimation(opacity);

// Після cancelAnimation значення залишається в поточному стані —
// анімація не завершується і не скидається до початкового значення.

Типовий сценарій — зупинити нескінченну анімацію завантаження при отриманні даних:

const loadingOpacity = useSharedValue(1);

// Запускаємо пульсацію:
loadingOpacity.value = withRepeat(withTiming(0.3, { duration: 700 }), -1, true);

// Дані прийшли — зупиняємо і показуємо контент:
const handleDataLoaded = () => {
  cancelAnimation(loadingOpacity);
  loadingOpacity.value = withTiming(0, { duration: 200 });
};

runOnJS: вийти в JavaScript Thread з worklet

Coли треба викликати звичайну JS функцію (setState, навігація, console.log) зсередини worklet:

import { runOnJS } from 'react-native-reanimated';

const setStateOnJS = runOnJS(setCount); // обгортаємо JS функцію

const gesture = Gesture.Tap().onEnd(() => {
  'worklet';
  // тут ми на UI Thread — setState напряму НЕ можна
  setStateOnJS(prev => prev + 1); // через runOnJS — можна
});
runOnJS додає мінімальну затримку (асинхронний перехід між потоками). Використовуйте його лише там де потрібно: setState, навігація, логування. Важлива анімаційна логіка — виключно в worklet, без runOnJS.

Мікроінтеракції: анімації кнопок і елементів UI

Мікроінтеракції — дрібні анімації на конкретні дії користувача. Вони роблять інтерфейс живим і «відчутним».

Анімована кнопка зі spring-натисканням

import Animated, {
  useSharedValue, useAnimatedStyle,
  withSpring, withTiming
} from 'react-native-reanimated';
import { Pressable } from 'react-native';

function SpringButton({ onPress, children }) {
  const scale = useSharedValue(1);
  const shadowOpacity = useSharedValue(0.25);

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
    shadowOpacity: shadowOpacity.value,
  }));

  return (
    <Pressable
      onPressIn={() => {
        scale.value = withSpring(0.94, { damping: 15, stiffness: 300 });
        shadowOpacity.value = withTiming(0.1, { duration: 100 });
      }}
      onPressOut={() => {
        scale.value = withSpring(1, { damping: 10, stiffness: 200 });
        shadowOpacity.value = withTiming(0.25, { duration: 200 });
      }}
      onPress={onPress}
    >
      <Animated.View style={[styles.button, animatedStyle]}>
        {children}
      </Animated.View>
    </Pressable>
  );
}

Живий приклад: різні мікроінтеракції

TSXApp.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Reduced Motion: анімації та доступність

Що таке Reduce Motion

На iOS і Android є системне налаштування «Зменшити рух» (Reduce Motion / Remove Animations). Воно призначене для людей з вестибулярними розладами, для яких інтенсивні анімації викликають дискомфорт або нудоту.

Якщо ваш застосунок ігнорує це налаштування — він порушує WCAG 2.1 (Success Criterion 2.3.3) і є недоступним для значної частини аудиторії.

Статистика: за даними Apple, близько 17% користувачів iPhone вмикають Reduce Motion. Це мільйони людей.

useReducedMotion у Reanimated

Reanimated 3 надає хук useReducedMotion, що повертає true якщо користувач увімкнув Reduce Motion:

import { useReducedMotion } from 'react-native-reanimated';

function MyComponent() {
  const reduceMotion = useReducedMotion();

  const handlePress = () => {
    if (reduceMotion) {
      // Миттєва зміна без анімації
      opacity.value = 0;
    } else {
      // Плавна анімація
      opacity.value = withTiming(0, { duration: 300 });
    }
  };
}

ReduceMotion enum у анімаційних функціях

Reanimated 3 також дозволяє налаштувати поведінку на рівні кожної анімаційної функції через reduceMotion опцію:

import { withTiming, withSpring, ReduceMotion } from 'react-native-reanimated';

// Варіанти:
opacity.value = withTiming(0, {
  duration: 300,
  reduceMotion: ReduceMotion.System, // дефолт: поважати системне налаштування
});

opacity.value = withTiming(0, {
  duration: 300,
  reduceMotion: ReduceMotion.Always, // завжди вимикати анімацію
});

opacity.value = withTiming(0, {
  duration: 300,
  reduceMotion: ReduceMotion.Never, // ніколи не вимикати
});
ReduceMotion.System
enum (дефолт)
Автоматично поважає системне налаштування «Зменшити рух». Анімація замінюється миттєвою зміною якщо Reduce Motion увімкнений. Рекомендований варіант.
ReduceMotion.Always
enum
Анімація завжди вимикається незалежно від системних налаштувань. Корисно для тестування або для анімацій, що не є необхідними.
ReduceMotion.Never
enum
Анімація ніколи не вимикається. Використовувати тільки для анімацій, що несуть критичну інформацію (наприклад, progress bar завантаження).

Layout Animations і Reduce Motion

Для entering/exiting/layout анімацій ReduceMotion також підтримується:

import Animated, { FadeIn, FadeOut, ReduceMotion } from 'react-native-reanimated';

<Animated.View
  entering={FadeIn.duration(300).reduceMotion(ReduceMotion.System)}
  exiting={FadeOut.duration(200).reduceMotion(ReduceMotion.System)}
>
  <Content />
</Animated.View>

Правила для анімацій та доступності

❌ Уникати

  • Паралакс і великі просторові переміщення
  • Нескінченні пульсуючі анімації без можливості зупинки
  • Анімації, що тривають > 3 секунд
  • Миготіння > 3 разів на секунду

✅ Безпечно навіть при Reduce Motion

  • Opacity fade-in/fade-out (дрібна зміна прозорості)
  • Кольорові переходи
  • Дрібний scale (0.97–1.03)
  • Progress bars і завантажувальні індикатори
Найкраща практика: завжди встановлюйте ReduceMotion.System у всіх анімаціях за замовчуванням. Це нічого не коштує у реалізації, але значно покращує доступність застосунку.

Міні-проєкт: «Expandable Card»

Що будуємо

Картка, яка при тапі:

  1. Розкривається через withSpring по висоті (від закритого стану до повного).
  2. Хевдер злегка піднімається і міняє колір.
  3. Контент з'являється через FadeIn зі затримкою.
  4. Іконка-стрілка плавно обертається на 180°.
  5. При повторному тапі — плавно закривається.
  6. Поважає Reduce Motion.

Reanimated-реалізація (повний код)

// components/ExpandableCard.tsx
import { useState, useCallback } from 'react';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withTiming,
  interpolate,
  interpolateColor,
  Extrapolation,
  FadeIn,
  FadeOut,
  ReduceMotion,
} from 'react-native-reanimated';
import { Pressable, Text, StyleSheet, View } from 'react-native';

interface Props {
  title: string;
  preview: string;
  children: React.ReactNode;
}

const COLLAPSED_HEIGHT = 64;
const EXPANDED_HEIGHT = 220;

export function ExpandableCard({ title, preview, children }: Props) {
  const [isOpen, setIsOpen] = useState(false);

  // 0 = закрито, 1 = відкрито
  const progress = useSharedValue(0);

  const handlePress = useCallback(() => {
    const next = isOpen ? 0 : 1;
    setIsOpen(!isOpen);
    progress.value = withSpring(next, {
      damping: 16,
      stiffness: 120,
      reduceMotion: ReduceMotion.System,
    });
  }, [isOpen, progress]);

  // Висота картки
  const containerStyle = useAnimatedStyle(() => ({
    height: interpolate(
      progress.value,
      [0, 1],
      [COLLAPSED_HEIGHT, EXPANDED_HEIGHT],
      Extrapolation.CLAMP
    ),
    overflow: 'hidden',
  }));

  // Колір фону хедера
  const headerStyle = useAnimatedStyle(() => ({
    backgroundColor: interpolateColor(
      progress.value,
      [0, 1],
      ['#FFFFFF', '#EFF6FF']
    ),
  }));

  // Обертання стрілки
  const arrowStyle = useAnimatedStyle(() => ({
    transform: [{
      rotate: `${interpolate(progress.value, [0, 1], [0, 180])}deg`
    }],
  }));

  // Прозорість прев'ю тексту (зникає при розкритті)
  const previewStyle = useAnimatedStyle(() => ({
    opacity: interpolate(progress.value, [0, 0.3], [1, 0], Extrapolation.CLAMP),
  }));

  return (
    <Animated.View style={[styles.card, containerStyle]}>
      {/* Хедер */}
      <Pressable onPress={handlePress}>
        <Animated.View style={[styles.header, headerStyle]}>
          <View style={styles.headerLeft}>
            <Text style={styles.title}>{title}</Text>
            <Animated.Text style={[styles.preview, previewStyle]} numberOfLines={1}>
              {preview}
            </Animated.Text>
          </View>
          <Animated.Text style={[styles.arrow, arrowStyle]}></Animated.Text>
        </Animated.View>
      </Pressable>

      {/* Контент, що з'являється */}
      {isOpen && (
        <Animated.View
          entering={FadeIn.delay(150).duration(250).reduceMotion(ReduceMotion.System)}
          exiting={FadeOut.duration(100).reduceMotion(ReduceMotion.System)}
          style={styles.content}
        >
          {children}
        </Animated.View>
      )}
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  card: {
    backgroundColor: 'white',
    borderRadius: 16,
    overflow: 'hidden',
    shadowColor: '#000',
    shadowOpacity: 0.08,
    shadowRadius: 12,
    shadowOffset: { width: 0, height: 4 },
    elevation: 4,
  },
  header: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: 18,
    paddingVertical: 14,
    minHeight: 64,
  },
  headerLeft: { flex: 1 },
  title: { fontSize: 16, fontWeight: '700', color: '#1E293B' },
  preview: { fontSize: 13, color: '#94A3B8', marginTop: 2 },
  arrow: { fontSize: 20, color: '#3B82F6', fontWeight: '700', marginLeft: 12 },
  content: { paddingHorizontal: 18, paddingBottom: 16 },
});

Живий preview: expandable card

TSXApp.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Підключення до Nomad: feat: micro-interactions with reanimated

У Nomad (застосунок для трекінгу подорожей) Reanimated природно з'являється у багатьох місцях:

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

package "Екран списку поїздок" {
  component [FadeIn при появі карток] as LIST_FADE
  component [Layout transition при видаленні] as LIST_LAYOUT
  component [Spring при pull-to-refresh] as LIST_REFRESH
}

package "Картка поїздки" {
  component [ExpandableCard (withSpring height)] as CARD_EXP
  component [Parallax фото-хедер] as CARD_PARALLAX
}

package "Кнопки та форми" {
  component [Spring scale при натисканні] as BTN_SCALE
  component [Shake при помилці валідації] as FORM_SHAKE
  component [Notification success/error] as FORM_NOTIF
}

package "Навігація" {
  component [Shared Element Transition\n(між списком і деталями)] as NAV_SHARED
}

LIST_FADE --> LIST_LAYOUT
CARD_EXP --> CARD_PARALLAX
BTN_SCALE --> FORM_SHAKE
@enduml

Рекомендовані місця для Reanimated у Nomad:

Список поїздок

entering={FadeIn.delay(index * 80)} для stagger появи карток. layout={LinearTransition} для плавного переміщення при видаленні поїздки.

Картка деталей

ExpandableCard для секції нотаток. useAnimatedScrollHandler для parallax фото вгорі екрана.

Кнопки

Spring scale (0.96) при onPressIn для всіх кнопок. withSequence(scale → 1.05 → 1.0) після успішного збереження.

Валідація форм

withSequence shake animation при помилці. FadeIn для error messages. withTiming opacity для placeholder тексту.

Комміт у Nomad: feat: micro-interactions with reanimated.


Поширені помилки та підводні камені


Підсумок

Потоки та worklets

  • JS Thread vs UI Thread — ключова різниця від Animated
  • Worklet виконується на нативному UI потоці
  • runOnJS() для виходу в JS при потребі
  • 'worklet' директива для кастомних функцій

Базові примітиви

  • useSharedValue — змінна двох потоків
  • useAnimatedStyle — worklet-зв'язок значень і стилів
  • useDerivedValue — обчислені значення
  • interpolate + Extrapolation.CLAMP

Анімаційні функції

  • withTiming + Easing.* — точний контроль
  • withSpring — фізика пружини (damping, stiffness)
  • withSequence, withDelay, withRepeat — композиція
  • cancelAnimation — зупинка

Layout Animations

  • entering, exiting, layout — одним рядком
  • FadeIn, SlideIn, ZoomIn, BounceIn і десятки інших
  • .duration(), .delay(), .springify()
  • ReduceMotion.System — обов'язково!
Наступний крок: поєднання Reanimated з RNGH (React Native Gesture Handler) дає максимальну потужність — жести і анімації разом на нативному потоці. Саме так побудовані bottom sheets, drag-to-dismiss, і всі складні інтеракції, які ви бачите у найкращих мобільних застосунках.
Copyright © 2026