Skip to main content

React Native Animation: Animated API vs Reanimated

Asit Kumar Panda15 min read
React Native Animation: Animated API vs Reanimated

React Native gives you four different ways to move pixels, and choosing the wrong one is the single most common reason an app ships animations that stutter on mid-range Android. This guide covers all four: the built-in Animated API, Reanimated 4, requestAnimationFrame, and the declarative wrapper libraries. Every example here is current as of React Native 0.87 and Reanimated 4.5.

Short answer: which animation API should you use?

  • Reanimated 4 for anything driven by a gesture, anything that tracks scroll position, and anything you would describe as "the interface responds to me". It runs animation code on the UI thread, so it keeps moving even when JavaScript is busy.
  • The built-in Animated API for fire-and-forget transitions: a fade-in on mount, a toast sliding up, a button pressing down. No extra dependency, no native rebuild, no worklets to reason about.
  • requestAnimationFrame almost never for visual animation. It is a scheduling primitive that runs on the JS thread. Use it to defer work, not to drive style.
  • react-native-animatable only if you are maintaining an app that already depends on it. Its last release was version 1.4.0, roughly three years ago, and it predates the New Architecture.

If you are building a new screen today and the animation is anything more involved than a fade, install Reanimated. The rest of this post explains why, and shows what each API actually looks like in production code.

The Animated API: what ships with React Native

Animated is built into React Native, so there is nothing to install and nothing to link. It is a good fit for self-contained transitions with a fixed start and end, and it is the right place to learn the concepts every other library reuses: animated values, easing, interpolation, and composition.

Animated.Value and the useAnimatedValue hook

An animated value is a mutable container that lives outside React's render cycle. That is the whole point: the value changes 60 times a second without triggering 60 re-renders.

The mistake almost everyone makes first is calling new Animated.Value(0) in the body of a function component. That creates a brand new value on every render, so any animation in flight is silently thrown away and the component snaps back to its starting position. React Native ships a hook for exactly this:

import { Animated, useAnimatedValue } from 'react-native';

function FadeInCard({ children }) {
  // Correct: stable across renders.
  const opacity = useAnimatedValue(0);

  // Wrong: a new value every render, animations get discarded.
  // const opacity = new Animated.Value(0);

  useEffect(() => {
    Animated.timing(opacity, {
      toValue: 1,
      duration: 300,
      useNativeDriver: true,
    }).start();
  }, [opacity]);

  return <Animated.View style={{ opacity }}>{children}</Animated.View>;
}

In class components, or in a module scope where hooks are not available, new Animated.Value(0) assigned to an instance field is still correct. Use Animated.ValueXY() when you are animating a two-dimensional quantity such as a drag offset, which saves you from managing two separate values by hand.

Animated.View, Animated.Text, and the rest

Ordinary components cannot read an animated value. You need the animated wrappers, and React Native exports six of them: Animated.View, Animated.Text, Animated.Image, Animated.ScrollView, Animated.FlatList, and Animated.SectionList.

Animated text is the one that trips people up. You cannot animate the string itself through the style prop, so animate a wrapper's opacity or transform and leave the text content to React. If you genuinely need a number that counts up on screen, use a Reanimated derived value and a text input's animated props rather than re-rendering a <Text> element every frame.

Anything else becomes animatable through Animated.createAnimatedComponent(), which is how you animate a third-party component or one of your own:

import { Pressable, Animated } from 'react-native';

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);

timing, spring, and decay

Three functions drive an animated value, and they map cleanly onto three kinds of motion.

Animated.timing() moves a value along an easing curve over a fixed duration. It defaults to 500ms with Easing.inOut(Easing.ease). Use it when you want a predictable, repeatable transition: modals, fades, and progress indicators.

Animated.spring() models a damped harmonic oscillator, which is a formal way of saying it moves the way physical objects move. It has no duration. You configure it with stiffness, damping, and mass (defaults 100, 10, and 1), or with the older friction and tension pair, or with bounciness and speed. Pick one set and stay in it. Use spring for anything the user directly manipulated, because it carries the velocity of their gesture through to the resting state.

Animated.decay() starts at a given velocity and slides to a stop, controlled by a deceleration coefficient that defaults to 0.997. This is the fling: the user throws a card, you hand decay the release velocity, and it settles naturally.

Each of these returns an animation object rather than starting immediately. Call .start() to run it, and pass a callback if you need to know when it finished:

Animated.spring(translateY, {
  toValue: 0,
  stiffness: 120,
  damping: 14,
  mass: 1,
  useNativeDriver: true,
}).start(({ finished }) => {
  if (finished) onSettled();
});

The finished flag matters. It is false when something called .stop() or started a competing animation on the same value, and treating an interrupted animation as a completed one is a reliable source of ghost state.

Interpolation: one value, many properties

Interpolation is what makes the Animated API worth learning. A single animated value maps onto as many style properties as you want, which means one scroll position can drive an entire collapsing header:

const scrollY = useAnimatedValue(0);

const headerHeight = scrollY.interpolate({
  inputRange: [0, 120],
  outputRange: [180, 64],
  extrapolate: 'clamp',
});

const titleOpacity = scrollY.interpolate({
  inputRange: [0, 60, 120],
  outputRange: [1, 0.4, 0],
  extrapolate: 'clamp',
});

<Animated.ScrollView
  scrollEventThrottle={16}
  onScroll={Animated.event(
    [{ nativeEvent: { contentOffset: { y: scrollY } } }],
    { useNativeDriver: true }
  )}
>
  {/* content */}
</Animated.ScrollView>

Always set extrapolate: 'clamp' unless you deliberately want values to run past the ends of your range. Without it, an over-scroll on iOS will happily drive your header height negative.

Composing animations: sequence, parallel, stagger, and loop

Four combinators cover almost every choreography you will need:

  • Animated.sequence([...]) runs animations one after another, waiting for each to finish.
  • Animated.parallel([...]) runs them all at once.
  • Animated.stagger(delay, [...]) starts them in order with a fixed gap between each, which is how list items cascade in.
  • Animated.loop(animation, { iterations }) repeats, infinitely by default.

There is also Animated.delay(ms), which is a no-op animation you drop into a sequence to pause. Values can be combined arithmetically too, through Animated.add, subtract, multiply, divide, modulo, and diffClamp. That last one is the standard trick for a header that hides on scroll down and reappears on scroll up.

useNativeDriver, and what changed in React Native 0.85

This is the flag that decides whether your animation survives contact with a busy app. With useNativeDriver: true, the entire animation is serialized and handed to the native side before it starts, and the native thread then updates the property every frame without asking JavaScript for anything. Your animation stays smooth even while JavaScript is parsing a large API response.

Historically the native driver only supported non-layout properties, which meant transform and opacity and not much else. Animating width, height, top, or margin forced you onto the JS thread. The standard workaround was to fake layout changes with scale and translate.

That constraint is now lifting. React Native 0.85 introduced a New Animation Backend, a shared animation engine underneath both Animated and Reanimated, and with it the ability to animate layout props with the native driver. It is experimental and available from 0.85.1 on the experimental release channel, so do not plan a production release around it yet. But the direction is clear, and it is worth knowing that the transform-only workaround has a shelf life.

Until then, the rule stands: if you cannot set useNativeDriver: true, ask whether you can restructure the animation so that you can.

Reanimated 4: animations that run on the UI thread

Reanimated solves a problem the Animated API cannot. The native driver works by shipping a complete animation description to native code ahead of time, which is why it only handles animations you can fully describe up front. The moment your animation needs to make a decision mid-flight, based on a gesture or a value that changed, it has to come back to JavaScript, and it inherits every stall in your JS thread.

Reanimated runs your animation logic itself on the UI thread, as worklets. Gesture handling, the decision about where a card should snap to, and the style calculation all happen off the JS thread. That is why gesture-driven interfaces feel different in Reanimated.

Installing Reanimated 4

Version 4 requires the React Native New Architecture (Fabric). If your app is still on the old architecture, either migrate or stay on the latest 3.x release, which remains supported.

Reanimated 4 also split worklets into their own package, so there are two installs:

npm install react-native-reanimated react-native-worklets
cd ios && pod install && cd ..

Then add the Babel plugin. It has to be the last entry in the plugins array, and getting this wrong produces confusing runtime errors about functions not being workletized:

// babel.config.js
module.exports = {
  presets: ['module:@react-native/babel-preset'],
  plugins: [
    // ...your other plugins
    'react-native-worklets/plugin', // must be last
  ],
};

On Expo, run npx expo prebuild to rebuild the native projects afterwards.

useSharedValue, useAnimatedStyle, withTiming, and withSpring

Reanimated's model is smaller than the Animated API's. A shared value holds state readable from both threads. An animated style is a worklet that derives style objects from shared values, and re-runs on the UI thread whenever they change. Animation helpers such as withTiming and withSpring wrap a target value to say "get there over time" rather than "be there now".

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

function ExpandingCard() {
  const expanded = useSharedValue(0);

  const animatedStyle = useAnimatedStyle(() => ({
    height: withSpring(expanded.value ? 320 : 96, {
      stiffness: 140,
      damping: 18,
    }),
    opacity: withTiming(expanded.value ? 1 : 0.7, { duration: 200 }),
  }));

  return (
    <Pressable onPress={() => (expanded.value = expanded.value ? 0 : 1)}>
      <Animated.View style={[styles.card, animatedStyle]} />
    </Pressable>
  );
}

Note what is missing: no useNativeDriver flag, and no special handling for height. Reanimated animates layout properties on the UI thread without the caveat, which on its own is enough to justify the dependency for a lot of screens.

Two rules save most of the debugging. Read and write .value only inside worklets or event handlers, never during render. And when you need to call back into React from the UI thread, wrap the call with runOnJS, because a worklet cannot touch your React state directly.

CSS animations in Reanimated 4

Reanimated 4 added a CSS-style animation API that is worth knowing about, because for simple looping or entrance animations it is dramatically less code. You declare keyframes as a plain object and hand it to the style prop:

const pulse = {
  from: { transform: [{ scale: 0.8 }, { rotateZ: '-15deg' }] },
  to: { transform: [{ scale: 1.2 }, { rotateZ: '15deg' }] },
};

function Badge() {
  return (
    <Animated.View
      style={{ animationName: pulse, animationDuration: '300ms' }}
    />
  );
}

Keyframe keys accept percentages ('0%', '50%', '100%'), the from and to aliases, or decimals between 0 and 1. Reanimated 4.5 extended this to shadow, background, and border properties, and added pseudo-selectors such as :hover. If you are coming from web work, this is the fastest way to be productive.

requestAnimationFrame in React Native: what it is actually for

requestAnimationFrame exists in React Native and works the way you expect from the browser: your callback runs before the next frame. What differs is the cost. It runs on the JS thread, so a callback that sets state and re-renders is doing a full React render, reconciliation, and native update, 60 times a second. That is the exact workload both the native driver and Reanimated exist to avoid.

So do not build animations on it. This pattern looks reasonable and will drop frames on any real device under load:

// Don't do this.
const tick = () => {
  setOffset((o) => o + 2); // full React render, every frame
  requestAnimationFrame(tick);
};

Where requestAnimationFrame earns its place is scheduling. Deferring a measurement until after layout has settled, breaking an expensive synchronous loop into frame-sized chunks so the UI stays responsive, or delaying a navigation transition by a frame so a press animation has time to render. For the common case of "run this once the animation finishes", InteractionManager.runAfterInteractions() is more direct and expresses the intent better.

react-native-animatable and the declarative wrappers

For years the quickest way to get a fade-in was react-native-animatable, which wraps components in a prop-driven API with a library of named presets:

import * as Animatable from 'react-native-animatable';

<Animatable.View animation="fadeInUp" duration={400} delay={100}>
  <Text>Welcome back</Text>
</Animatable.View>

It is still pleasant to use, and it is still in a lot of codebases. But its most recent release, version 1.4.0, is roughly three years old, it is built on the legacy Animated API, and it has had no updates for the New Architecture. Treat it as maintenance-only: fine to keep in an existing app, not a dependency to add to a new one.

If you want that declarative feel on a modern stack, Reanimated's own entering and exiting layout animations cover the same ground with none of the maintenance risk:

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

<Animated.View entering={FadeInUp.duration(400).delay(100)} exiting={FadeOut}>
  <Text>Welcome back</Text>
</Animated.View>

Debugging an animation that stutters

When motion feels wrong, work through this list in order. It resolves most cases before you reach the profiler.

  • Test on a real mid-range Android device. Simulators and flagship phones hide everything. A three-year-old Android handset is the honest baseline.
  • Turn off dev mode. Animations in a debug build with the debugger attached are not representative. Profile in release.
  • Check every useNativeDriver. One animation left on the JS thread will stutter alongside all the well-behaved ones.
  • Look for state updates during the animation. A parent re-rendering mid-animation is the most common cause of a stutter that only happens sometimes.
  • Watch the two frame rates separately. The performance monitor reports JS and UI thread frame rates independently. A healthy UI thread with a collapsing JS thread points at work that belongs off the main path. Both dropping points at genuinely expensive rendering.
  • Animate transform and opacity where you can. Even with the new backend landing, these remain the cheapest properties to move.
  • Check your list rendering. Animating rows in a FlatList that is also mounting new rows means competing for the same frames. Fix the list first.

Frequently asked questions

What is the difference between Animated and Reanimated?

Animated is built into React Native and describes animations ahead of time so native code can run them. Reanimated is a separate library that runs your animation logic itself on the UI thread as worklets, which lets animations respond to gestures and changing values mid-flight without touching the JS thread.

Do I need Reanimated if I already use the Animated API?

Not for simple fades and transitions. You need it as soon as animations must react to a gesture in real time, track scroll position with logic attached, or animate layout properties smoothly on the current stable React Native release.

Can I animate text in React Native?

You can animate a text element's opacity, transform, and color by rendering it inside Animated.Text. You cannot animate the string content through style. For a number that counts up on screen, drive an animated text input's props from a Reanimated derived value rather than re-rendering a text node every frame.

Why does my animation reset or jump back to the start?

Almost always because the animated value is being recreated on every render. Use useAnimatedValue() in function components with the Animated API, or useSharedValue() with Reanimated, instead of constructing the value inline.

Does useNativeDriver work with width and height?

Not on current stable releases, where the native driver covers transforms and opacity. React Native 0.85's experimental New Animation Backend removes that limitation, but until it is stable, either use Reanimated for layout animation or express the change as a scale or translate.

Which Reanimated version should I install?

Reanimated 4.x if your app is on the New Architecture, which it requires. Reanimated 3.x if you are still on the old architecture. The 4.x line is at 4.5 and is where new features such as the CSS animations API are landing.

Is react-native-animatable still safe to use?

In an existing app, yes, with the understanding that it is not being updated. Its last release was version 1.4.0, roughly three years ago, and it has no New Architecture support. For anything new, use Reanimated's entering and exiting animations instead.

Where this matters in production

Animation quality is the difference between an app that feels native and one that feels like a website in a shell. It is also one of the first things a user notices and one of the last things most teams get to. The pattern we see repeatedly is an app where every individual animation is written correctly but the JS thread is so oversubscribed that none of them can run smoothly, and the fix is architectural rather than a matter of tuning easing curves.

We build React Native apps that hold 60fps on the devices customers actually carry. If you're planning a mobile build or trying to work out why an existing one feels sluggish, our mobile app development team can help.

Ready to build something that matters?

We solve problems that don't have Stack Overflow answers. Let's talk.

Book a Discovery Call