DEV Community

Mark F A
Mark F A

Posted on

Reanimated vs Moti vs Skia: stop picking one

TL;DR

  • Reanimated runs animation math on the UI thread via worklets. It is the foundation, and you will end up with it installed regardless.
  • Moti is a declarative wrapper over Reanimated with a Framer Motion-style API. Great for enter/exit, skeletons, staggered lists.
  • Skia is Shopify's binding to Google's 2D graphics engine. Paths, shaders, blurs. Not an animation library.
  • These are not competitors. They are three layers of one stack, and most production apps use at least two.
  • Skia props accept Reanimated shared values directly, with no wrapper component. That single fact is why the combination is everywhere.

The framing problem

Every "X vs Y vs Z" post assumes you are picking one. With these three that assumption is wrong, and it produces two specific failure modes: teams that write 40 lines of Reanimated hooks to fade in a card, and teams that reach for Moti on a pan gesture and then cannot bind it to anything.

Here is what each one actually is at the runtime level.

Reanimated: worklets on the UI thread

Animations that run on the JS thread stutter whenever your app does work. Parsing JSON, rendering a list, handling touch. Reanimated moves the math onto the UI thread using worklets, small JS functions the runtime serializes and executes natively through JSI.

Three pieces:

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

function Card() {
  const offset = useSharedValue(0);

  const style = useAnimatedStyle(() => ({
    transform: [{ translateX: offset.value }],
  }));

  return (
    <Animated.View style={style} onTouchEnd={() => (offset.value = withSpring(100))} />
  );
}
Enter fullscreen mode Exit fullscreen mode

useSharedValue is a reactive primitive living on the UI thread, readable and writable from both sides. useAnimatedStyle is a worklet that re-runs whenever a shared value it reads changes. withTiming and withSpring drive the value over time.

Updates are scheduled against display frames rather than the JS event loop. That is why the animation stays smooth while your JS thread renders a 50-item list.

Wire it to a gesture and you get the thing Reanimated is actually for:

import { Gesture, GestureDetector } from 'react-native-gesture-handler';

const pan = Gesture.Pan().onChange((e) => {
  offset.value += e.changeX;
});

return (
  <GestureDetector gesture={pan}>
    <Animated.View style={style} />
  </GestureDetector>
);
Enter fullscreen mode Exit fullscreen mode

No runOnJS, no bridge hop. The gesture callback is a worklet and it mutates a UI-thread value in the same frame.

The cost: ceremony. Shared values, worklet directives on nested functions, useAnimatedStyle for every animated view. It is more machinery than a fade-in deserves.

Moti: the same thing, four lines

<MotiView
  from={{ opacity: 0, translateY: 20 }}
  animate={{ opacity: 1, translateY: 0 }}
  exit={{ opacity: 0 }}
  transition={{ type: 'timing', duration: 300 }}
/>
Enter fullscreen mode Exit fullscreen mode

Under the hood this is Reanimated. Same worklets, same UI-thread execution. You just never touch useSharedValue for the common cases. You also get AnimatePresence for exits, MotiText and MotiImage, a <Skeleton /> loader, and <Sequence /> for chaining.

Staggering a list is where the ergonomics really show:

{items.map((item, i) => (
  <MotiView
    key={item.id}
    from={{ opacity: 0, translateY: 12 }}
    animate={{ opacity: 1, translateY: 0 }}
    transition={{ delay: i * 50 }}
  />
))}
Enter fullscreen mode Exit fullscreen mode

The cost: the moment you need a live gesture driving the animation, you drop back to raw Reanimated. There is no way to bind a MotiView prop to an ongoing pan translation. useDynamicAnimation exists, but using it puts you back in imperative code and the abstraction stops paying rent.

Skia: not an animation library at all

It is the 2D engine behind Chrome, Flutter, and Android's rendering pipeline, exposed to RN through a declarative component API. You use it for what the view tree cannot express: paths, SkSL shaders, blurs, blend modes, gradients beyond LinearGradient, text on curves.

The detail that matters for this comparison:

import { Canvas, Circle } from '@shopify/react-native-skia';
import { useSharedValue, withTiming } from 'react-native-reanimated';

const cx = useSharedValue(0);
cx.value = withTiming(200, { duration: 600 });

<Canvas style={{ flex: 1 }}>
  <Circle cx={cx} cy={100} r={40} color="hotpink" />
</Canvas>
Enter fullscreen mode Exit fullscreen mode

Skia props accept Reanimated shared values directly. No createAnimatedComponent, no useAnimatedProps. The canvas re-renders on the UI thread as the value changes. That is the pattern under Victory Native XL, Reanimated Carousel, and basically every good animated chart in the ecosystem.

The cost: no Expo Go, so you need a dev client. Roughly 2 MB of native binary. And SkSL is its own idiom with a real learning curve.

Comparison

Reanimated Moti Skia
Type Animation runtime Declarative wrapper 2D graphics engine
Runs on UI thread (worklets) UI thread (via Reanimated) UI thread (JSI)
Best for Gestures, sequences Enter/exit, transitions Drawings, shaders
Depends on None Reanimated 2/3/4 None
Approx. cost ~150 KB JS + native ~30 KB JS + Reanimated ~2 MB native binary
Expo Go Yes (SDK 47+) Yes No, dev client
Maintainer Software Mansion Fernando Rojo Shopify

What to reach for

Task Library
Fade in a card on mount Moti
Drive a value from a pan or pinch Reanimated
Chain coordinated view animations Moti
Animated chart with line and dots Skia + Reanimated
Blur a background as a sheet drags up Skia + Reanimated
Layout animation on list add/remove Reanimated 4 LinearTransition
Skeleton loader while fetching Moti <Skeleton />
Shader-based splash screen Skia

The dependency graph makes composition cheap. Moti pulls in Reanimated, so the incremental cost of "adding" Reanimated once you have Moti is zero. Skia is the only real commit, and you pay for it once.

The performance thing nobody mentions

All three run on the UI thread over the same JSI bridge, so raw frame timing is comparable for comparable work. Where it actually breaks in production is almost never the library. It is this pattern:

// This will drop frames
const style = useAnimatedStyle(() => ({
  height: h.value,        // triggers layout every frame
}));

// This will not
const style = useAnimatedStyle(() => ({
  transform: [{ scaleY: h.value }],  // compositor only
}));
Enter fullscreen mode Exit fullscreen mode

Animating a layout property schedules a layout pass on every frame and the "60fps animation" ends up queued behind it. All three libraries let you do this. If you are chasing a stutter, profile before you refactor.

What building an AI generator taught us

We build RapidNative, which turns natural-language prompts into React Native and Expo code. Picking the animation primitive without a human in the loop surfaced two things worth stealing.

Users never say "gesture." They say "make it feel snappy" or "add some polish." We default to Moti for appearance verbs (fade, slide, pop, stagger), escalate to Reanimated the moment a prompt implies user-driven motion (drag, swipe, pull to refresh, pinch), and only pull in Skia for charts, shaders, gradients, or explicitly artistic phrasing.

Composition beats picking a tool. The worst early bug was choosing one library per screen. A screen with a draggable card and an animated hero wants Reanimated for the drag and Moti for the hero. Separate concerns, separate primitives.

That generalizes past AI. Even when a human is choosing, the either/or framing costs you.

FAQ

Is Moti just Reanimated with less code? Mostly. Same worklets, same UI-thread execution, same shared values internally. Moti adds prop-based config, AnimatePresence, sequence orchestration, and prebuilt components.

Can I use Skia without Reanimated? Yes, for static scenes and animations driven by its own value primitives. Almost nobody does, because shared values are the standard driver everywhere else in the app.

Does Reanimated 4 replace Moti? No. The CSS-like syntax narrows the ergonomic gap, but AnimatePresence and the declarative prop API are still more concise for common transitions.

What about the built-in Animated API? Wrong default for new code. JS thread by default, useNativeDriver covers only a subset of transforms and opacity, verbose interpolation, painful gesture integration.

Bottom line

Install Reanimated and Moti. Leave Skia until the design brief demands it. If you are auditing an existing codebase, the two things you will find are Moti calls that should be Reanimated (wired to a gesture later, never rewritten) and Reanimated hooks that should be Moti (fading a card in, 30 unnecessary lines).

Pick the primitive per interaction, not per app.

What is your current animation stack, and have you hit the point where you needed to mix them? Drop a comment with what you are building.

Top comments (0)