How we made an Expo app feel native: Reanimated 4, real form sheets and haptics A developer on the 6pm money app team documented how the app achieves native-feeling interactions in Expo by running all animations on the UI thread with Reanimated 4 worklets and Gesture Handler, centralizing motion curves and gesture thresholds in a shared @6pm/theme package, and using a UIScrollView-style velocity projection function to decide card-swipe verdicts on release. The team also adopted Expo Router form sheets and four distinct haptic types, and noted a worklet gotcha where default parameters referencing imported constants can be undefined on the UI thread. An Expo app can feel native if every animation runs on the UI thread, every gesture hands its velocity to the spring that finishes it, and you use the system's own sheets instead of drawing your own. In 6pm that means Reanimated 4 worklets, Gesture Handler, Expo Router form sheets, and four kinds of haptic, each used for one job. 6pm is a daily money app. Its core moment, "the close", is a stack of receipt cards you swipe through at 6pm: right to keep, left to edit, down for "not mine". The last card stamps the day CLOSED. If that swipe feels like a web page, the product fails. So our agent contract the CLAUDE.md at the repo root opens with this: Native feel is the product. UI-thread animation only Reanimated worklets, Gesture Handler . Every state change animates with the prototype's curve. No layout jumps, no blank frames, no JS-thread Animated . Here is how that rule turns into code. Versions, from the workspace catalog: Expo 58, React Native 0.88 New Architecture, Hermes , react-native-reanimated 4.7.0, react-native-worklets 0.13.0, react-native-gesture-handler 3.2.1, expo-router 58.0.19, expo-haptics 58.0.6. The design started as an HTML prototype with CSS keyframes and cubic-beziers. Rather than eyeballing it, the curves, durations, springs and gesture thresholds live in one place, @6pm/theme : js // packages/theme/src/motion.ts trimmed export const curves = { / The house curve: a confident move with a small overshoot cards, sheets, chips . / house: 0.2, 0.9, 0.25, 1.12 , / Entrances: screens, list items, hero cards. / enter: 0.2, 0.9, 0.2, 1 , / Wheel pickers snapping to a row. / snap: 0.2, 0.9, 0.25, 1 , } as const; export const gestures = { cardFling: 90, rowConfirm: 90, rowRevealDelete: -48, rowCommitDelete: -150, sheetDismiss: 120, tabSwipe: 70, fabLongPressMs: 480, } as const; Screens import the numbers, never invent them, so motion can be reviewed like any other diff. The biggest difference between "animated" and "native" is what happens when the finger lets go. UIKit decides with velocity, not just position: a quick short flick commits, a slow long drag doesn't. We use the same projection UIScrollView uses: // apps/mobile/src/ui/motion/physics.ts / Where a released finger was heading: the distance it would still travel if it kept slowing at UIScrollView's normal rate. A quick short flick projects far and commits; a slow long drag doesn't. velocity in points per second, as Gesture Handler reports it. / export function project velocity: number, decelerationRate = 0.998 : number { 'worklet'; return velocity / 1000 decelerationRate / 1 - decelerationRate ; } The close card uses it to pick a verdict. Only the axis the finger mostly travelled counts, so a mostly downward drag that drifts right is "not mine", never "keep": // apps/mobile/src/features/close/card-fling.ts export function cardFling x: number, y: number, velocityX: number, velocityY: number, threshold?: number : CardFling { 'worklet'; // Read here, not as a default parameter: a worklet captures only names its body uses. const reach = threshold ?? gestures.cardFling; const px = x + project velocityX ; const py = y + project velocityY ; if Math.abs px = Math.abs py { if px reach return 'kept'; if px < -reach return 'edit'; return null; } return py reach ? 'notMine' : null; } That comment about default parameters is a real gotcha: a worklet closure captures only what its body references, so a default parameter that reads an imported constant can come up undefined on the UI thread. The function is pure, so it has a unit test card-fling.test.ts that runs in Jest without a device. The card stack is a Gesture.Pan writing to two shared values. Three details make it feel physical: // apps/mobile/src/features/close/close-cards.tsx trimmed Gesture.Pan .onStart e = { // A card caught mid-flight is picked up where it is. const y = dy.get ; startX.set dx.get - e.translationX ; startY.set y < 0 ? unband y, stack : y - e.translationY ; } .onUpdate e = { const y = startY.get + e.translationY; dx.set startX.get + e.translationX ; dy.set y = 0 ? y : rubberband y, stack ; } .onEnd e = { const verdict = cardFling dx.get , dy.get , e.velocityX, e.velocityY ; if verdict === null { settle e.velocityX, e.velocityY ; return; } // The tap lands as the finger lets go, not when the card is gone. scheduleOnRN haptics.tap ; // ... const away = { ...release.away, reduceMotion }; if verdict === 'kept' { dx.set withSpring AWAY X, { ...away, velocity: e.velocityX }, done ; } else { dy.set withSpring AWAY Y, { ...away, velocity: e.velocityY }, done ; } } ; onStart runs the inverse unband to find the finger offset that produces the current position. No jump. export function rubberband overshoot: number, dimension: number, constant = 0.55 : number { 'worklet'; return overshoot dimension constant / dimension + constant Math.abs overshoot ; } js export const release = { home: { duration: 400, dampingRatio: 0.8 }, sheet: { duration: 300, dampingRatio: 0.8 }, away: { duration: 300, dampingRatio: 1, overshootClamping: true }, } as const; The card tilts as it moves rotate: dx / 18 degrees , computed in useAnimatedStyle . And because each new top card mounts with its own drag at zero, nothing is "reset" after a fling: the card behind is already where the stack had moved it. The buttons under the card Keep, Edit, Not mine do the same thing without a finger. They have no velocity to carry, so they use withTiming on the house ease-out instead of a spring. At first, every sheet in 6pm drew itself over a transparent modal with Reanimated. It looked right in screenshots and felt wrong in the hand. On 12 October we switched to the real thing. The reasoning is recorded in docs/tech-decisions.md : The native sheet drags from anywhere, pulls down from the top of a scroll, carries the finger's velocity, is interruptible, honours Reduce Motion, and slides away on router.back ; the hand-drawn one did none of that well. With Expo Router that is a screen option: js // apps/mobile/src/ui/navigation/presentations.ts export const sheet: ScreenOptions = { presentation: Platform.OS === 'ios' && Platform.isPad ? 'pageSheet' : 'formSheet', sheetAllowedDetents: 1 , sheetCornerRadius: 40, sheetGrabberVisible: false, headerShown: false, contentStyle: { backgroundColor: 'transparent' }, }; That is UISheetPresentationController underneath via react-native-screens . Trade-offs we accepted: Two sheets stay hand-drawn because iOS cannot draw them: a floating card inset from the edges, and the Looks sheet, which sinks low over an undimmed page while you try a theme on. Below its tallest detent, iOS floats its sheet inset from the edges, so neither shape can be native. Those use a shared drag-to-dismiss hook that follows the same rules as the native sheet: decide by where the flick was heading position plus projected velocity , spring home at {300 ms, ratio 0.8} or out at {300 ms, ratio 1, clamped} with the finger's velocity, and continue from wherever it is if grabbed mid-flight. When the last card goes, the day prints: a receipt feeds down out of a slot, and 1.6 s later a red CLOSED stamp lands. The prototype's stampIn keyframes scale 2.4 → 0.94 → 1, rotate −14° → −7°, opacity to 0.92 become one shared value driven by a sequence, with the keyframe maths in the animated style: js // apps/mobile/src/features/close/close-receipt.tsx trimmed const stampEase = Easing.bezier 0.3, 1.5, 0.5, 1 ; const STAMP MS = { delay: 1600, land: 300, rest: 200 }; stamp.value = withDelay STAMP MS.delay, withSequence withTiming 0.6, { duration: STAMP MS.land, easing: stampEase }, = runOnJS haptics.tap , , withTiming 1, { duration: STAMP MS.rest } , , ; const stampStyle = useAnimatedStyle = { const t = stamp.value; const scale = t < 0.6 ? 2.4 - t / 0.6 2.4 - 0.94 : 0.94 + t - 0.6 / 0.4 0.06; return { opacity: Math.min 0.92, t / 0.6 , transform: { rotate: ${-14 + 7 Math.min 1, t / 0.6 }deg }, { scale } , }; } ; The haptic fires in the timing's completion callback, the instant the stamp hits paper. That timing is the whole trick: a thump 100 ms late reads as a bug. The prototype had one buzz . iOS offers several feelings, so we split it into a tiny vocabulary and never call expo-haptics directly from a screen: js // apps/mobile/src/ui/haptics.ts export const haptics = { / Tab scrub, wheel row, a swipe crossing its threshold. / tick: = void Haptics.selectionAsync , / Keep / edit / not mine, FAB long-press, sheet dismissed, undo. / tap: = void Haptics.impactAsync Haptics.ImpactFeedbackStyle.Light , / Something let go of for good: a row flung away to delete. / commit: = void Haptics.impactAsync Haptics.ImpactFeedbackStyle.Medium , / The close done, the reveal settling, a purchase made. / success: = void Haptics.notificationAsync Haptics.NotificationFeedbackType.Success , }; From a gesture worklet, haptics hop to the JS thread with scheduleOnRN Reanimated 4 moved thread hopping into react-native-worklets . Two rules we follow: "Every state change animates" includes text. The daily number is an odometer: each digit is a column that rolls, 1000 ms plus 80 ms per position, on the entrance curve. When the number grows a digit 99 → 100 , the new column opens from zero width while the others glide over, using Reanimated layout animations written as worklets: js // apps/mobile/src/ui/odometer.tsx const slide = LinearTransition.duration durations.screen .easing ease ; No re-render can make the number jump, because the layout change itself is animated on the UI thread. Respecting the accessibility setting is not an afterthought you add with a flag at the end. In 6pm: reduceMotion: ReduceMotion.System , so iOS's setting is honoured in the spring itself. docs/tech-decisions.md says springs become short fades, atmosphere freezes and nothing loops. project , rubberband , cardFling are a few lines each, run on the UI thread, and are unit-tested in Jest. presentation: 'formSheet' . The 4 pt we lost bought interruptibility, scroll hand-off and accessibility. Yes, if animation and gesture handling run on the UI thread. Reanimated worklets and Gesture Handler do that; the JS-thread Animated API and state-driven re-renders don't. Use Expo Router's presentation: 'formSheet' where it fits: you get UISheetPresentationController's drag, scroll hand-off, velocity and Reduce Motion for free. Draw your own only for shapes iOS can't present. Project the release velocity with UIScrollView's deceleration rate, add it to the current offset, and compare against a threshold on the dominant axis. A short fast flick commits; a slow drag springs back. All of this is open source under Apache 2.0: github.com/at6pm/6pm https://github.com/at6pm/6pm see apps/mobile/src/ui/motion , apps/mobile/src/features/close and packages/theme/src/motion.ts . Swipe the close yourself in the public beta on TestFlight: testflight.apple.com/join/a8UrvT5P https://testflight.apple.com/join/a8UrvT5P . 6pm is on the App Store from Friday 6 November 2026, 18:00 ICT. More at at6pm.app https://at6pm.app . Written with AI assistance from our own code and design docs; every snippet is from the repo.