cd /news/developer-tools/how-we-made-an-expo-app-feel-native-… · home › topics › developer-tools › article
[ARTICLE · art-154498] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

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.

read9 min views3 publishedOct 21, 2026

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:

// 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:

// 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:

// 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:

// 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:

// 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 (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. 6pm is on the App Store from Friday 6 November 2026, 18:00 ICT. More at at6pm.app.

Written with AI assistance from our own code and design docs; every snippet is from the repo.

── more in #developer-tools 4 stories · sorted by recency
── more on @6pm 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-we-made-an-expo-…] indexed:0 read:9min 2026-10-21 · —