Native-feeling Expo components with Reanimated and Gesture Handler
Build mobile UI in Expo that runs animations on the UI thread with Reanimated and Gesture Handler, with tilt, budget ring and swipe examples.
React Native apps feel "not quite native" for one main reason: animations and gestures that wait on the JavaScript thread. When JS is busy parsing a response or rendering a list, frames drop. Reanimated and Gesture Handler fix this by running animation and gesture logic on the UI thread. Every Moveset native component is built on them.
Install
In an Expo project, use expo install so versions match your SDK:
npx expo install react-native-reanimated react-native-gesture-handler react-native-svgWrap your app root in GestureHandlerRootView. Follow the Reanimated docs for your SDK version on whether a Babel plugin entry is needed; recent Expo templates configure it for you.
import { GestureHandlerRootView } from "react-native-gesture-handler";
export default function App() {
return <GestureHandlerRootView style={{ flex: 1 }}>{/* screens */}</GestureHandlerRootView>;
}The core model: shared values and worklets
Reanimated's building blocks are small:
useSharedValueholds a number that lives on the UI thread.useAnimatedStylemaps shared values to styles, re-evaluated on the UI thread every frame.withSpringandwithTiminganimate a shared value toward a target.
const scale = useSharedValue(1);
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
// on press in: scale.value = withSpring(0.97, snappy)
// on press out: scale.value = withSpring(1, snappy)Because the styles never round-trip through React state, a busy JS thread does not stutter the animation.
Share spring presets across web and native
Moveset keeps one set of spring numbers for both platforms. Reanimated's withSpring accepts the same stiffness, damping and mass as Motion, so a component tuned on the web feels the same in the app:
export const nativeSpring = {
snappy: { stiffness: 520, damping: 34, mass: 1 },
soft: { stiffness: 220, damping: 26, mass: 1 },
heavy: { stiffness: 140, damping: 22, mass: 1.4 },
} as const;Example: a tilt card driven by a pan gesture
Gesture Handler's Gesture.Pan() reports positions on the UI thread, so you can write straight into shared values:
import { TiltCard } from "@/components/moveset/tilt-card.native";
<TiltCard width={220} height={280} max={9}>
<PlanContents />
</TiltCard>Inside, the pan's onUpdate maps finger position to rotateX and rotateY, and onEnd springs both back to zero with the soft preset.
Example: an animated SVG ring
Progress rings need to animate an SVG attribute, not a style. Reanimated's useAnimatedProps together with Animated.createAnimatedComponent(Circle) handles that:
import { BudgetRing } from "@/components/moveset/budget-ring.native";
<BudgetRing spent={640} limit={800} size={168} stroke={14} />The ring springs strokeDashoffset toward the new value whenever spent changes.
Example: swipe actions with a threshold
Swipe-to-archive rows are a pan gesture with a decision at the end: past the threshold, complete the action; otherwise spring back. Use runOnJS to call your JS callback from the gesture worklet. Moveset's SwipeActionItem and TransactionList (Pro) implement this pattern.
Reduced motion on mobile
Reanimated exports useReducedMotion(), which reads the OS accessibility setting. Moveset's native components use it to replace springs with instant changes. Make sure the state change still happens; only the travel goes away.
Checklist
- Animate shared values, not React state.
- Use gestures from Gesture Handler, not the
PanResponderAPI, for anything continuous. - Reuse the same spring presets as your web app.
- Test on a real mid-range Android device, not just the iOS simulator.
The free pack includes Expo versions of Animated Hero, Tilt Card, Animated Tabs, Balance Card and Budget Ring.