React Native Reanimated vs Moti vs React Native Skia: Animation 2026
TL;DR
Smooth animations are the difference between a native-feeling React Native app and a janky one. React Native Reanimated v3 is the foundation — it moves animation calculations to the UI thread via worklets, giving you 60-120fps animations even when the JS thread is busy. Moti is a developer-friendly wrapper on top of Reanimated — a Framer Motion-like declarative API that handles transitions with from/animate props, making common animations 10x less code. React Native Skia operates at a different level — it's a 2D graphics engine backed by Google's Skia renderer, enabling custom drawing, shaders, image filters, and animations that CSS transforms cannot achieve. For UI transitions and micro-interactions: Moti (built on Reanimated). For custom graphics, blurs, and canvas drawing: React Native Skia. For gesture-driven, complex animation sequences: Reanimated directly.
Key Takeaways
- Reanimated v3 worklets run on UI thread — no JS bridge for animation updates = 60fps guarantee
- Moti API is Framer Motion-like —
<MotiView from={{ opacity: 0 }} animate={{ opacity: 1 }} /> - React Native Skia is GPU-accelerated — renders via Skia's Metal (iOS) and Vulkan (Android)
- Reanimated GitHub stars: ~9k — the standard animation foundation for RN
- Moti adds ~30KB to bundle — thin wrapper, no significant overhead on top of Reanimated
- Skia supports shaders — custom GLSL-like shader effects impossible with standard RN
- All three work with Expo — Reanimated is included in Expo SDK by default
The React Native Animation Stack
App Layer:
Moti ← Declarative, Framer-like (recommended for most UI)
└── Reanimated ← Foundation, imperative, gesture-driven (complex sequences)
Graphics Layer:
React Native Skia ← Canvas, shaders, image filters (custom 2D rendering)
React Native's default Animated API runs on the JS thread — susceptible to dropped frames when your JS thread is busy. Reanimated/Moti/Skia all run on the UI thread.
React Native Reanimated: The Animation Foundation
Reanimated v3 uses "worklets" — small JavaScript functions that compile to native bytecode and run on the UI thread, bypassing the JS bridge entirely.
Installation
npx expo install react-native-reanimated
# Add plugin to babel.config.js:
# plugins: ['react-native-reanimated/plugin']
Shared Values and Animations
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withSpring,
withRepeat,
withSequence,
Easing,
interpolate,
Extrapolate,
} from "react-native-reanimated";
function FadeInCard() {
const opacity = useSharedValue(0);
const scale = useSharedValue(0.9);
// Animate when component mounts
useEffect(() => {
opacity.value = withTiming(1, { duration: 300 });
scale.value = withSpring(1, { damping: 15, stiffness: 300 });
}, []);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ scale: scale.value }],
}));
return (
<Animated.View style={[styles.card, animatedStyle]}>
<Text>Hello!</Text>
</Animated.View>
);
}
Gesture-Driven Animation (with Gesture Handler)
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from "react-native-reanimated";
function SwipeableCard({ onDismiss }: { onDismiss: () => void }) {
const translateX = useSharedValue(0);
const rotate = useSharedValue(0);
const pan = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
rotate.value = (event.translationX / 200) * 15; // Max 15 degrees
})
.onEnd((event) => {
if (Math.abs(event.translationX) > 120) {
// Swipe out
const direction = event.translationX > 0 ? 1 : -1;
translateX.value = withTiming(direction * 500, { duration: 300 }, () => {
runOnJS(onDismiss)(); // Call JS function from UI thread
});
} else {
// Snap back
translateX.value = withSpring(0);
rotate.value = withSpring(0);
}
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ rotate: `${rotate.value}deg` },
],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={[styles.card, animatedStyle]}>
<Text>Swipe me!</Text>
</Animated.View>
</GestureDetector>
);
}
Complex Sequences and Interpolation
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withSequence,
withTiming,
interpolate,
Extrapolate,
} from "react-native-reanimated";
function PulsingDot() {
const scale = useSharedValue(1);
useEffect(() => {
scale.value = withRepeat(
withSequence(
withTiming(1.3, { duration: 600 }),
withTiming(1, { duration: 600 })
),
-1, // Infinite repeats
false // Don't reverse
);
}, []);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
// Interpolate: map scale 1→1.3 to opacity 0.6→1
opacity: interpolate(scale.value, [1, 1.3], [0.6, 1], Extrapolate.CLAMP),
}));
return <Animated.View style={[styles.dot, animatedStyle]} />;
}
Moti: Framer Motion for React Native
Moti is a declarative animation library built on Reanimated. Instead of imperatively setting shared values, you declare from and animate states.
Installation
npx expo install moti
# Moti requires Reanimated (install it first)
Declarative Animations
import { MotiView, MotiText, MotiImage } from "moti";
// Simple fade in on mount
function FadeInCard() {
return (
<MotiView
from={{ opacity: 0, translateY: 10 }}
animate={{ opacity: 1, translateY: 0 }}
transition={{ type: "timing", duration: 350 }}
>
<Text>Hello!</Text>
</MotiView>
);
}
// Toggle animation based on state
function Toggle() {
const [active, setActive] = useState(false);
return (
<Pressable onPress={() => setActive(!active)}>
<MotiView
animate={{
backgroundColor: active ? "#5469d4" : "#e5e7eb",
scale: active ? 1.05 : 1,
}}
transition={{ type: "spring", damping: 15 }}
style={styles.button}
/>
</Pressable>
);
}
Skeleton Loading with Moti
import { MotiView } from "moti";
import { Skeleton } from "moti/skeleton";
// Moti's Skeleton component — shimmer effect for loading states
function UserCardSkeleton() {
return (
<View style={styles.card}>
<Skeleton colorMode="light" radius="round" height={50} width={50} />
<View style={{ marginLeft: 10 }}>
<Skeleton colorMode="light" height={16} width={120} />
<View style={{ height: 8 }} />
<Skeleton colorMode="light" height={12} width={80} />
</View>
</View>
);
}
Staggered List Animation
import { MotiView } from "moti";
function AnimatedList({ items }: { items: string[] }) {
return (
<View>
{items.map((item, index) => (
<MotiView
key={item}
from={{ opacity: 0, translateX: -20 }}
animate={{ opacity: 1, translateX: 0 }}
transition={{
type: "timing",
duration: 300,
delay: index * 80, // Stagger by 80ms per item
}}
>
<Text style={styles.item}>{item}</Text>
</MotiView>
))}
</View>
);
}
Presence Animations (Mount/Unmount)
import { AnimatePresence, MotiView } from "moti";
function Modal({ visible, children }: { visible: boolean; children: React.ReactNode }) {
return (
<AnimatePresence>
{visible && (
<MotiView
key="modal"
from={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }} // Animate out on unmount
transition={{ type: "spring", damping: 20 }}
style={styles.modal}
>
{children}
</MotiView>
)}
</AnimatePresence>
);
}
React Native Skia: GPU-Accelerated Canvas
React Native Skia brings the Skia 2D graphics engine to React Native — the same engine powering Chrome, Flutter, and Android. Use it for custom 2D graphics, blur effects, image filters, and effects impossible with standard RN transforms.
Installation
npx expo install @shopify/react-native-skia
Basic Canvas Drawing
import {
Canvas,
Circle,
Path,
Skia,
useDrawCallback,
Paint,
LinearGradient,
vec,
Group,
RoundedRect,
} from "@shopify/react-native-skia";
function GradientCard() {
const width = 300;
const height = 200;
return (
<Canvas style={{ width, height }}>
{/* Rounded rectangle with gradient */}
<RoundedRect x={0} y={0} width={width} height={height} r={16}>
<LinearGradient
start={vec(0, 0)}
end={vec(width, height)}
colors={["#5469d4", "#9333ea"]}
/>
</RoundedRect>
{/* Circle with blur */}
<Circle cx={150} cy={100} r={60} opacity={0.3}>
<Paint color="white" />
</Circle>
</Canvas>
);
}
Image Filters and Effects
import {
Canvas,
Image,
useImage,
BlurMask,
ColorMatrix,
Group,
} from "@shopify/react-native-skia";
function ProcessedImage() {
const image = useImage(require("./photo.jpg"));
if (!image) return null;
return (
<Canvas style={{ width: 300, height: 300 }}>
<Group>
{/* Grayscale filter */}
<ColorMatrix
matrix={[
0.21, 0.72, 0.07, 0, 0,
0.21, 0.72, 0.07, 0, 0,
0.21, 0.72, 0.07, 0, 0,
0, 0, 0, 1, 0,
]}
/>
<Image image={image} x={0} y={0} width={300} height={300} fit="cover" />
</Group>
</Canvas>
);
}
Skia Animations (Combined with Reanimated)
import { Canvas, Circle, Fill } from "@shopify/react-native-skia";
import { useSharedValue, withRepeat, withTiming, useDerivedValue } from "react-native-reanimated";
function AnimatedRipple() {
const radius = useSharedValue(20);
useEffect(() => {
radius.value = withRepeat(
withTiming(80, { duration: 1000 }),
-1,
true // Reverse
);
}, []);
// Convert Reanimated shared value to Skia selector
const r = useDerivedValue(() => radius.value);
return (
<Canvas style={{ width: 200, height: 200 }}>
<Fill color="white" />
<Circle cx={100} cy={100} r={r} color="#5469d4" opacity={0.3} />
<Circle cx={100} cy={100} r={20} color="#5469d4" />
</Canvas>
);
}
Feature Comparison
| Feature | Reanimated v3 | Moti | React Native Skia |
|---|---|---|---|
| API style | Imperative | Declarative | JSX canvas |
| Built on | Native | Reanimated | Skia engine |
| Custom paths/shapes | ❌ | ❌ | ✅ |
| Image filters | ❌ | ❌ | ✅ Blur, ColorMatrix |
| Shaders (GLSL) | ❌ | ❌ | ✅ |
| Gesture integration | ✅ Native | Via Reanimated | Via Reanimated |
| Declarative transitions | Manual | ✅ from/animate | Manual |
| Skeleton loading | Manual | ✅ Built-in | Manual |
| Presence animations | Manual | ✅ AnimatePresence | Manual |
| Expo SDK | ✅ Included | ✅ | ✅ |
| Bundle size | Large (worklets) | Small (thin wrapper) | Large (Skia engine) |
| GitHub stars | 9k | 3.7k | 6.5k |
When to Use Each
Choose Reanimated directly if:
- Complex gesture-driven interactions (swipe, drag, pinch) are core to your UI
- You need fine-grained control over animation timing and sequencing
- You're building a custom gesture library or animation system
- The declarative Moti API doesn't cover your use case
Choose Moti if:
- Standard UI transitions (fade, slide, scale on mount/unmount) are what you need
- Skeleton loading screens need to be built quickly
- You want Framer Motion-like DX —
from,animate,exitprops - Staggered list animations with minimal code
Choose React Native Skia if:
- You need custom 2D graphics (charts, drawing tools, game-like UI)
- Image effects (blur, grayscale, color adjustments) are needed
- Animated backgrounds, gradients, or particle effects
- Custom paths and shapes that CSS transforms cannot achieve
Performance Profiling and Frame Drop Diagnosis
Understanding why an animation drops frames in React Native requires knowing which thread is the bottleneck. React Native Flipper's Performance panel and the built-in Performance Monitor (shake the device → Show Performance Monitor) display both the JS thread frame rate and the UI thread frame rate separately. If the UI thread shows 60fps but the JS thread drops, your animations are fine but something else in your JavaScript is blocking. If the UI thread drops, the bottleneck is in the native rendering layer — too many views, large Skia canvas draws, or synchronous JS-to-UI bridge calls during animations. Reanimated worklets run on the UI thread, so their animations survive a frozen JS thread. However, calling runOnJS() within a worklet creates a JS thread dependency that can cause hitching if the called function is slow. Profile before optimizing — most apparent performance issues in React Native are caused by slow JavaScript list renders or heavy component trees, not animation code.
Accessibility Considerations for Animated UIs
Animations can be a significant accessibility barrier for users with vestibular disorders or motion sensitivity. iOS and Android both expose a "Reduce Motion" accessibility setting that well-designed apps should respect. Reanimated provides access to this setting via the useReducedMotion() hook — when it returns true, swap spring and timing animations for instant state changes or simpler fade-only transitions. Moti's transition prop can be conditionally set based on this hook, making it easy to provide reduced-motion-friendly variants of your animations. React Native Skia animations require manual reduced-motion handling since they are driven by Reanimated shared values — the same useReducedMotion() check applies. Users who have enabled "Reduce Motion" on their device expect apps to honor that preference; failing to do so causes genuine physical discomfort for some users and reflects poorly on your app's accessibility posture in App Store reviews.
Skia and Reanimated in Production App Size
Adding React Native Skia to your app adds approximately 4–6 MB to the iOS IPA and Android APK due to the Skia native library binary. Reanimated's worklet runtime adds approximately 1–2 MB. These additions are meaningful for apps targeting markets with limited storage devices or slow download connections. Before committing to Skia for visual effects that could be achieved with standard RN transforms, evaluate whether the feature genuinely requires GPU canvas drawing. Gradient card backgrounds, for example, can be achieved with Expo LinearGradient (which uses native gradient APIs without Skia) at a fraction of the bundle size cost. Skia is essential for blur effects (BlurMask), custom shader effects, image color matrix filters, and canvas-based data visualization — these cannot be replicated with standard React Native view styling. The 4–6 MB addition is justified when Skia enables capabilities that would otherwise require a WebView, which adds even more overhead.
Combining Reanimated, Moti, and Skia in a Single App
The three libraries compose cleanly because Moti is built on Reanimated and Skia accepts Reanimated shared values natively. A common production pattern: use Moti for standard UI transitions (screen entry/exit animations, skeleton loaders, toggle states), use Reanimated directly for gesture-driven interactions (swipe-to-delete, drag-to-reorder, pinch-to-zoom), and use Skia for any custom graphics that appear alongside those interactions (chart backgrounds, blur overlays, decorative shape animations). Shared values from Reanimated flow into Skia canvas elements via useDerivedValue(), meaning your gesture handler can simultaneously drive both a React Native view transform (via Reanimated) and a Skia canvas element (via the derived value) at 60fps on the UI thread. This pattern is used in Victory Native XL, which combines all three libraries to power interactive data visualization with gesture-driven chart scrubbing and Skia-rendered line paths.
Methodology
Data sourced from GitHub repositories (star counts as of February 2026), official documentation for all three libraries, bundle size analysis from bundlephobia.com and Expo build output analysis, and performance benchmarks from the React Native community on Twitter/X and the React Native Discord. Moti documentation from nandorojo.com/moti.
Related: NativeWind vs Tamagui vs twrnc for styling libraries that complement animations, or FlashList vs FlatList vs LegendList for list performance in React Native.
See also: React vs Vue and React vs Svelte