Animation That Helps

Durations and easings that feel native, honouring reduce motion, and animating to explain a change rather than to decorate it.

SyntaxwithTiming(1, { duration: 220, easing: Easing.out(Easing.quad), reduceMotion: ReduceMotion.System })

Motion has one job: to show what changed and where it went. Everything else is cost, paid by every user on every launch.

Durations and easings

Press feedback 100-150ms. A chevron, a badge, a row closing a gap 150-250ms. A sheet or a full-width card 250-350ms. Past 400ms a transition feels slow the second time, and users see it hundreds of times. Easing: decelerate into place for things arriving (Easing.out(Easing.quad)), accelerate away for things leaving, symmetric for a move — Easing.inOut(Easing.quad) is Reanimated's withTiming default. Keep Easing.linear for spinners and progress only. Springs suit anything a finger was just touching; the duration form, withSpring(1, { duration: 400, dampingRatio: 1 }), is easier to tune than stiffness and damping, and dampingRatio: 1 means no overshoot. The real settle takes about 1.5 times the duration you give.

Reduce motion

Both platforms have a system setting for this, and there is no prefers-reduced-motion media query here to read it with. Reanimated animations default to ReduceMotion.System, so withTiming and withSpring already honour it. For your own branching, useReducedMotion() returns the value read at app start and does not re-render when it changes, while AccessibilityInfo.isReduceMotionEnabled() returns a promise and AccessibilityInfo.addEventListener('reduceMotionChanged', handler) keeps up live. Honouring the setting means replacing movement with a fade or an instant change, not removing the feedback.

What not to animate

Nothing that delays the first interaction, nothing repeated on every keystroke, and nothing that moves a tap target under a finger already on its way to it.

Example

Example · tsx
import { useState } from 'react';
import { Pressable, StyleSheet, Text } from 'react-native';
import Animated, {
  LinearTransition,
  ReduceMotion,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

type DetailsCardProps = {
  title: string;
  body: string;
};

export default function DetailsCard({ title, body }: DetailsCardProps) {
  const [open, setOpen] = useState(false);
  const reduceMotion = useReducedMotion();
  const progress = useSharedValue(0);

  const toggle = () => {
    progress.value = withTiming(open ? 0 : 1, {
      // 220 ms: long enough to follow, short enough to repeat all day
      duration: reduceMotion ? 0 : 220,
      reduceMotion: ReduceMotion.System, // the default, stated to be explicit
    });
    setOpen(!open);
  };

  const chevronStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${progress.value * 90}deg` }],
  }));

  return (
    <Animated.View
      style={styles.card}
      layout={LinearTransition.duration(220).reduceMotion(ReduceMotion.System)}>
      <Pressable onPress={toggle} style={styles.header}>
        <Text style={styles.title}>{title}</Text>
        <Animated.Text style={chevronStyle}>&gt;</Animated.Text>
      </Pressable>
      {open ? <Text>{body}</Text> : null}
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  card: { margin: 16, padding: 14, borderRadius: 12, backgroundColor: '#ffffff', gap: 8 },
  header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
  title: { fontSize: 16, fontWeight: '600' },
});

When to use it

  • Choose a duration for a sheet that still feels quick on the hundredth open.
  • Honour reduce motion without taking away the feedback that a tap registered.
  • Replace a jarring list reorder with a transition that shows where rows went.
  • Put every duration and easing in the app behind one shared module.

More examples

Motion tokens in one module

A plain module with no components in it, so its behaviour can be reasoned about and tested. Three named durations and two config factories mean a reviewer can see at a glance whether a screen is using the house timings, and a change to the feel of the app is one edit rather than forty. Reach for this the second time you type a duration literal into a component.

Example · tsx
// motion.ts - every duration in the app, decided once
import { Easing, ReduceMotion, type WithTimingConfig } from 'react-native-reanimated';

export const DURATION = {
  tap: 120, // press feedback
  small: 200, // a chevron, a badge, a row closing a gap
  surface: 280, // a sheet or a card the size of the screen
} as const;

// Arriving: decelerate into place.
export function enter(reduceMotion: boolean): WithTimingConfig {
  return {
    duration: reduceMotion ? 0 : DURATION.small,
    easing: Easing.out(Easing.quad),
    reduceMotion: ReduceMotion.System,
  };
}

// Leaving: accelerate away, and a little quicker than arriving.
export function exit(reduceMotion: boolean): WithTimingConfig {
  return {
    duration: reduceMotion ? 0 : DURATION.tap,
    easing: Easing.in(Easing.quad),
    reduceMotion: ReduceMotion.System,
  };
}

console.log(enter(true).duration);
// Output: 0
console.log(enter(false).duration);
// Output: 200
console.log(exit(false).duration);
// Output: 120

Keeping up with the reduce-motion setting

On screen: each new message fades in over 200ms, or appears instantly if the device has reduce motion enabled - including when the user turns that setting on while the app is open. Note the shape of the effect: one promise for the current value, one subscription for changes, and a cleanup that removes it. A fade is usually the right reduced alternative, because it carries no movement but still marks that something changed.

Example · tsx
import { useEffect, useState } from 'react';
import { AccessibilityInfo, StyleSheet, Text, View } from 'react-native';
import Animated, { FadeIn } from 'react-native-reanimated';

type MotionAwareBannerProps = {
  message: string;
};

export default function MotionAwareBanner({ message }: MotionAwareBannerProps) {
  const [reduceMotion, setReduceMotion] = useState(false);

  useEffect(() => {
    let active = true;
    // isReduceMotionEnabled returns Promise<boolean>
    AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
      if (active) {
        setReduceMotion(enabled);
      }
    });
    // useReducedMotion is read once at startup; this stays current
    const subscription = AccessibilityInfo.addEventListener(
      'reduceMotionChanged',
      (enabled) => setReduceMotion(enabled),
    );
    return () => {
      active = false;
      subscription.remove();
    };
  }, []);

  return (
    <View style={styles.wrapper}>
      <Animated.View
        key={message} // a new message is a new element, so entering runs again
        entering={reduceMotion ? undefined : FadeIn.duration(200)}
        style={styles.banner}>
        <Text>{message}</Text>
      </Animated.View>
    </View>
  );
}

const styles = StyleSheet.create({
  wrapper: { padding: 16 },
  banner: { padding: 12, borderRadius: 8, backgroundColor: '#fff8e1' },
});

Animating to explain, not to decorate

On screen: when the price changes, a yellow tint rises behind it in 150ms and drains away over the next 300ms; the digits themselves never move. The animation exists only to answer a question the user would otherwise have to ask - which number changed? Contrast it with a bouncing or sliding price, which is harder to read and draws the eye away from the figure. Use this pattern for live totals, stock levels and anything that updates while being looked at.

Example · tsx
import { useEffect } from 'react';
import { StyleSheet, Text } from 'react-native';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withTiming,
} from 'react-native-reanimated';

type PriceProps = {
  cents: number;
};

export default function Price({ cents }: PriceProps) {
  const highlight = useSharedValue(0);

  useEffect(() => {
    // a brief flash that says "this number just changed", then gets out of the way
    highlight.value = withSequence(
      withTiming(1, { duration: 150 }),
      withTiming(0, { duration: 300 }),
    );
  }, [cents, highlight]);

  const animatedStyle = useAnimatedStyle(() => ({
    backgroundColor: `rgba(255, 235, 59, ${highlight.value * 0.6})`,
  }));

  return (
    <Animated.View style={[styles.pill, animatedStyle]}>
      <Text style={styles.text}>{(cents / 100).toFixed(2)}</Text>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  pill: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 8 },
  text: { fontSize: 18, fontWeight: '600' },
});

Discussion

  • Be the first to comment on this lesson.