Instruction file imported from jp2507-max/GrowBro (
.github/instructions/styling-guidelines.instructions.md). Copyright stays with the author.
React Native Reanimated Guidelines (4.x, Expo SDK 54)
Worklets — What runs on the UI thread
- Auto‑workletization: callbacks passed to Reanimated APIs (
useAnimatedStyle,useDerivedValue, gesture callbacks, entering/exiting/layout) run on the UI runtime. - Add
'worklet'when you (1) call imported/external functions, (2) create worklets via expressions/ternaries, (3) define worklet callbacks inside custom hooks, or (4) expose reusable top‑level worklet utilities. runOnUI: inline callbacks are workletized automatically; external references still need'worklet'.- Never read
.valuein React render; derive inside worklets. Assign to shared values; avoid deep object mutations. - One write per frame: don’t set the same shared value multiple times in a single tick.
- No hooks in worklets.
// Auto‑workletized (UI thread)
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
// Imported function as worklet
export function cardWorklet() {
'worklet';
return { opacity: 1 };
}
// Expression‑defined worklet
const makeStyle = isOn
? () => {
'worklet';
return { opacity: 1 };
}
: () => {
'worklet';
return { opacity: 0.5 };
};
✅ Do / Avoid (Quick)
Do: Tailwind for static, Reanimated for dynamic; respect Reduced Motion.
Avoid: per-frame class churn; per-frame runOnJS.
🧠 Worklet Offloading (TL;DR)
- If logic runs per frame/gesture and doesn’t need React state, make it a worklet.
- Candidates: interpolation/physics, clamping/throttling, hit‑testing, gesture math, small in‑memory filters/scoring tied to UI.
- One‑shot heavy calc tied to UI:
runOnUI(() => {
'worklet';
// expensive but synchronous logic here
})();
Captures (Closures)
- Capture only small, serializable values. Avoid large objects/functions; pass params or use Shared Values.
runOnJS — DO / DON'T
DO: Haptics/toasts, analytics, logging, update React state after animation/gesture.
DON'T: Call per frame or inside onUpdate loops; timing‑critical UI logic.
Async & Side‑Effects
- Worklets are synchronous & side‑effect‑free (no network/storage/timers). For async/IO, jump to JS via
runOnJS.
Quick Perf Check
- Use Expo Dev Menu FPS monitor; ensure animations stay smooth while JS is busy.
- Log only on events (start/finish) via
runOnJS, not every frame.
Class Churn vs Animated Style
Bad (recomputes classes every frame):
// ❌ don't flip classes per frame
<View className={progress.value > 0.5 ? 'opacity-100' : 'opacity-50'} />
Good:
const opacity = useSharedValue(0.5);
const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
return <Animated.View style={style} className="bg-primary rounded-xl" />;
🎛️ Animation Strategy & Syntax (Cheat)
1. State-Driven Animations (Continuous/Toggle)
Use useSharedValue + useAnimatedStyle with withTiming or withSpring for reactive state changes.
- APIs:
useSharedValue,useAnimatedStyle,withTiming,withSpring
import { useEffect } from 'react';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withSpring,
interpolateColor,
ReduceMotion,
} from 'react-native-reanimated';
import { motion } from '@/lib/animations/motion';
function ToggleBox({ isActive }: { isActive: boolean }) {
const width = useSharedValue(100);
const bgProgress = useSharedValue(0);
useEffect(() => {
width.value = withSpring(isActive ? 200 : 100, {
reduceMotion: ReduceMotion.System,
});
bgProgress.value = withTiming(isActive ? 1 : 0, {
duration: motion.dur.md,
reduceMotion: ReduceMotion.System,
});
}, [isActive]);
const animatedStyle = useAnimatedStyle(() => ({
width: width.value,
backgroundColor: interpolateColor(
bgProgress.value,
[0, 1],
['#0000ff', '#ff0000']
),
}));
return <Animated.View style={animatedStyle} className="h-20 rounded-lg" />;
}
2. Looping/Keyframe-Like Animations (Spinners, Skeletons)
Use withRepeat and withSequence for infinite or multi-step animations. Always use cancelAnimation for cleanup.
- APIs:
withRepeat,withSequence,cancelAnimation
import { useEffect } from 'react';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withSequence,
withTiming,
cancelAnimation,
Easing,
ReduceMotion,
} from 'react-native-reanimated';
function PulsingDot() {
const scale = useSharedValue(1);
const opacity = useSharedValue(0.5);
useEffect(() => {
scale.value = withRepeat(
withSequence(
withTiming(1.2, {
duration: 500,
easing: Easing.inOut(Easing.ease),
reduceMotion: ReduceMotion.System,
}),
withTiming(1, {
duration: 500,
easing: Easing.inOut(Easing.ease),
reduceMotion: ReduceMotion.System,
})
),
-1,
true
);
opacity.value = withRepeat(
withSequence(
withTiming(1, { duration: 500, reduceMotion: ReduceMotion.System }),
withTiming(0.5, { duration: 500, reduceMotion: ReduceMotion.System })
),
-1,
true
);
// ⚠️ Cancel on unmount
return () => {
cancelAnimation(scale);
cancelAnimation(opacity);
};
}, []);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
return (
<Animated.View
style={animatedStyle}
className="size-4 rounded-full bg-primary-500"
/>
);
}
3. Layout Animations (Mount/Unmount)
List items, conditional rendering. Always wrap with withRM (from src/lib/animations/motion).
- APIs:
FadeIn,ZoomOut,SlideInUp,LinearTransition
<Animated.View
entering={withRM(FadeInUp.springify())}
exiting={withRM(ZoomOut.duration(200))}
layout={LinearTransition}
/>
4. Shared Values (Interactive) Gestures, Scroll, Sensors. The "Heavy Lifting".
- APIs:
useSharedValue,useAnimatedStyle,Gesture(RNGH v2),GestureDetector - Logic: Keep math in worklets; avoid
runOnJSinonUpdate - Legacy Warning: Never use
useAnimatedGestureHandler(v1). UseGesture.Pan().onUpdate(...)(v2)
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
} from 'react-native-reanimated';
function DraggableBox() {
const offsetX = useSharedValue(0);
const offsetY = useSharedValue(0);
const startX = useSharedValue(0);
const startY = useSharedValue(0);
const gesture = Gesture.Pan()
.onStart(() => {
startX.value = offsetX.value;
startY.value = offsetY.value;
})
.onUpdate((e) => {
offsetX.value = startX.value + e.translationX;
offsetY.value = startY.value + e.translationY;
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offsetX.value }, { translateY: offsetY.value }],
}));
return (
<GestureDetector gesture={gesture}>
<Animated.View
style={animatedStyle}
className="size-20 rounded-lg bg-primary-500"
/>
</GestureDetector>
);
}
🔗 Shared element transitions
- Use
sharedTransitionTagwith a prefixed domain, e.g.,feed.card.image,settings.avatar. - Centralize optional
sharedTransitionStyleinsrc/lib/animations/shared.ts. - Name tags predictably; avoid collisions by prefixing with feature.
🖐️ Modern gestures (RNGH v2)
- Use the
Gesturebuilder API withGestureDetector. - Replace old
useAnimatedGestureHandler(3.x) withonStart/onUpdate/onEndchain. - Keep your own shared
ctxviauseSharedValueif needed. - Heavy math stays in UI worklets; no
runOnJSinsideonUpdate.
♻️ Cleanup & chaining
- Cancel long/looping animations on unmount (
cancelAnimation). - Use composition helpers to chain sequences; fire follow‑up animations from finish callbacks.
🔀 Crossing threads
- UI → JS:
runOnJS(fn)(args...)only for side‑effects, analytics, or updating React state after animation/gesture. - JS → UI:
runOnUI(() => { 'worklet'; /* ui logic */ })(). - Keep boundaries coarse‑grained; never call
runOnJSper frame.
🚨 Pitfalls (4.x)
- Calling React hooks inside worklets (don’t).
- Reading
.valueinside React render or outside a worklet. - Large closure captures; prefer primitives/params/shared values.
- Per‑frame
classNamechurn; derive styles from shared values. - Multiple writes to the same shared value in one frame.
- Forgetting
cancelAnimationon long/looping sequences. - Overusing
runOnJSinonUpdatehandlers.
⚙️ Expo SDK 54 specifics
- Reanimated 4.x is bundled with SDK 54.
- RNGH: v2 Gesture API.
- Babel:
react-native-reanimated/pluginviababel-preset-expo→ no manual changes typically needed. - Install deps with the Expo‑pinned versions:
npx expo install react-native-reanimated react-native-gesture-handler.
Short agent take: Tailwind for static, Reanimated for dynamic; keep className stable; prefer layout/shared transitions; honor Reduced Motion; use tokens; prefix sharedTransitionTag by feature; keep heavy logic on the UI runtime and cross to JS only for side‑effects/state.
🧱 Motion tokens & Reduced Motion (GrowBro)
- Centralize durations and easings so animations feel consistent and can be themed.
// src/lib/animations/motion.ts
import { Easing, ReduceMotion } from 'react-native-reanimated';
export const motion = {
dur: { xs: 120, sm: 180, md: 260, lg: 360 },
ease: {
standard: Easing.bezier(0.2, 0, 0, 1),
emphasized: Easing.bezier(0.2, 0, 0, 1),
decel: Easing.bezier(0, 0, 0.2, 1),
},
};
export const withRM = (anim: any) =>
anim.reduceMotion?.(ReduceMotion.System) ?? anim;
Use
entering={withRM(FadeInUp.duration(motion.dur.md).easing(motion.ease.standard))}
withRMensures system Reduced Motion is always respected.
🤝 Gesture composition (cheat)
Gesture.Simultaneous(pan, pinch)— both can run.Gesture.Exclusive(press, pan)— press wins unless pan exceeds threshold.Gesture.Race(longPress, tap)— first to activate cancels others.
Heavy math stays in
onUpdateworklets; userunOnJSonly inonEnd.
🧭 Scroll recipe (programmatic)
const scrollRef = useAnimatedRef<Animated.ScrollView>();
scrollTo(scrollRef, 0, y.value, true);
- Prefer
scrollToover style/position hacks; keepyas a shared value.
🏷️ Shared values naming (GrowBro)
- Prefix with feature + unit:
feedY,cardScale,opacityA. - Derived values suffix
D:cardScaleDderived fromcardScale.
✅ QA checklist (ultra‑short)
- Reduced Motion respected everywhere?
- List insert/remove uses
layoutand looks smooth? - Any per‑frame
runOnJSor class churn left? - Looping animations canceled on unmount?
- Style keys stable per frame; compute once in
useDerivedValue, reuse across styles.