You know that feeling when one app becomes part of your daily routine while another gets deleted the same day you install it — even though both do roughly the same thing? In most cases the difference is not the feature set or even the visual design. It is the 0.1 seconds of feedback that comes back to your finger when you tap something.
Rork is great at generating screens that look polished, but the way those screens feel when you touch them tends to default to something bland. In this article I want to share the three implementation details that, in my experience, do the most to lift a Rork-built app from "serviceable" to "actually nice to use" — animation, haptics, and transition speed — with code you can drop into your project this afternoon.
Animation: Aim for "no friction" Before "delight"
When people hear "animation" they often imagine the flashy, attention-grabbing kind. But for apps users open every day, the goal is not delight — it is the absence of friction. If a button gives no feedback on press, the user wonders whether the tap even registered. If the feedback is too loud, the interface feels noisy and hard to trust. The sweet spot is a response that feels like it is exactly keeping pace with your finger.
A tap-feedback component you can reuse everywhere
Rork often generates buttons that either do nothing on press or fall back to TouchableOpacity's fairly plain opacity dip. Replacing those with a Reanimated-based scale animation is a small change that dramatically improves the app's overall feel.
// components/PressableScale.tsx
// Purpose: a button that scales to 0.96x while pressed and eases back on release
import React from "react";
import { Pressable, PressableProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
Easing,
} from "react-native-reanimated";
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
export function PressableScale(props: PressableProps & { children: React.ReactNode }) {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<AnimatedPressable
{...props}
onPressIn={(e) => {
// Press-down should feel instantaneous — 80ms keeps it "right there"
scale.value = withTiming(0.96, {
duration: 80,
easing: Easing.out(Easing.quad),
});
props.onPressIn?.(e);
}}
onPressOut={(e) => {
// Release eases out slightly slower so the motion has weight
scale.value = withTiming(1, {
duration: 160,
easing: Easing.out(Easing.quad),
});
props.onPressOut?.(e);
}}
style={[animatedStyle, props.style as any]}
>
{props.children}
</AnimatedPressable>
);
}Drop this in place of your main CTAs — Save, Submit, Buy, the buttons users commit to — and the whole app starts feeling more responsive.
The key trick is asymmetric timing: press-down fast (80ms), release slower (160ms). That small difference is what separates "sluggish" from "twitchy" and lands you in the comfortable middle. If you want to go further with gesture-driven motion, Rork × Reanimated and Gesture Handler: advanced animation patterns covers springs and composed gestures.
Haptics: Reserve Them for Commitment Moments
The Taptic Engine on iPhone and the vibration motor on Android are powerful, which is exactly why overusing them cheapens an app fast. A common mistake is wiring haptics into every single tap. Within minutes users feel buzzed out and turn haptics off at the OS level.
The rule I follow: haptics only fire when the user commits to something.
The three moments that deserve haptics
In my own apps I restrict haptic feedback to these three moments:
- Selection confirmations — picker changes, tab switches, segment toggles. A barely-there tick confirms "yes, that selection landed."
- Success notifications — saves, sends, successful purchases. A soft pulse confirms completion without the user having to look at the screen.
- Just before irreversible actions — delete, unsubscribe, publish. A firmer impact reinforces "this just happened."
Scrolling a list or tapping navigation does not earn a haptic. Adding feedback to motions the user is not consciously making turns the whole app into noise.
A tiny wrapper around expo-haptics
If your Rork project uses Expo, expo-haptics gives you everything you need.
// utils/haptics.ts
// Purpose: centralise haptic usage so the rules stay consistent across screens
import * as Haptics from "expo-haptics";
export const hapticFeedback = {
// Tab switches, segment controls (light tick)
selection: () => Haptics.selectionAsync(),
// Save / submit / purchase success
success: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success),
// Delete / irreversible action confirmation
impact: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium),
// Input error — use sparingly, it stresses users out over time
warning: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning),
};And from the call site:
import { hapticFeedback } from "@/utils/haptics";
async function handleSave() {
await saveMemo();
hapticFeedback.success(); // the user feels the save land
}An important constraint: at most two haptic styles per screen. If selection and success both fire on the same screen, neither conveys its meaning clearly. Pick one "main event" per screen that earns impact or success, and let everything else use selection at most.
If you want the full tour, Rork Haptic Feedback: a complete implementation guide walks through every pattern I have found useful.
Transition Speed: Aim for "no stall" Between Tap and Screen
The third piece is the time between the user's tap and visible feedback on the next screen. This is the most overlooked and the most impactful area.
Roughly speaking, users register something happening within 100ms as "the app responded," and anything over 300ms starts to register as "this stalled." It is common to see 400–600ms on Rork-generated screens, and almost always the fix is the same pattern.
Measure before you optimise
Guessing at "this feels slow" does not lead anywhere. React Native gives you enough to measure transition time directly.
// hooks/useTransitionTimer.ts
// Purpose: log milliseconds between tap and interactive-ready state (dev only)
import { useEffect } from "react";
import { InteractionManager } from "react-native";
export function useTransitionTimer(label: string) {
useEffect(() => {
const start = Date.now();
const handle = InteractionManager.runAfterInteractions(() => {
const duration = Date.now() - start;
if (__DEV__) console.log(`[transition] ${label}: ${duration}ms`);
});
return () => handle.cancel();
}, [label]);
}Drop the hook into your main screen components and scan the numbers. The delay almost always comes from one of three places:
- Synchronous API calls before first paint: the screen waits on data before rendering anything
- Large images loaded over the network before paint: the layout stays empty while assets arrive
- Heavy state calculations inside render: the full
map/filteron every update
The fix: separate the frame from the data
The principle is "paint the frame immediately, fill in the data afterwards."
// screens/FeedScreen.tsx
// Purpose: show the skeleton frame instantly; swap in data once it arrives
import { useQuery } from "@tanstack/react-query";
import { FlatList, View } from "react-native";
import { SkeletonRow } from "@/components/SkeletonRow";
import { FeedRow } from "@/components/FeedRow";
export function FeedScreen() {
const { data, isLoading } = useQuery({
queryKey: ["feed"],
queryFn: fetchFeed,
staleTime: 60_000, // cache is trusted for a minute — avoids refetch on re-entry
});
if (isLoading) {
// At least 6 skeleton rows so there is never blank space on screen
return (
<View>
{Array.from({ length: 6 }).map((_, i) => (
<SkeletonRow key={i} />
))}
</View>
);
}
return (
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <FeedRow item={item} />}
initialNumToRender={8}
windowSize={5}
/>
);
}The detail that matters most here is "never show an empty spinner on a blank screen." A grey skeleton feels meaningfully faster than a spinning wheel in empty space, because the eye registers shape before it registers animation. For broader app-level startup tuning — splash minimisation, asset prewarming — see Rork app launch speed: practical optimisation guide.
Before / After: What Changes When You Apply All Three
Picture the same screen before and after these three changes.
Before (the Rork default):
- The main button dims slightly on press, but the response feels thin
- Save succeeds silently — the user has to watch the screen to know it worked
- Navigation pauses for 500ms on a spinner-in-empty-space before the next screen appears
After (with all three details applied):
- The button glides to 0.96x under the finger and back, giving tangible feedback
- Save completes with a small "tick" you feel without looking
- The next screen paints immediately as a skeleton; the data slides in behind
Same features, same palette, same layout. But the perception shifts from "fine" to "actually nice to use." After I rolled these changes into my own production apps, user reviews started mentioning "snappy" and "feels smooth" — words that almost never come from features alone.
Wrap-Up: One Thing to Try Today
You do not need to apply all three at once. Pick the single most-tapped button in your app and combine PressableScale with hapticFeedback.success. That is it.
Which button to pick depends on the app — the Save button in a note-taking app, the Add-to-Cart button in an e-commerce app, the Post button in a social app. When the one button users press most starts to feel good, the whole app starts to feel a level better. It is a ten-minute change with disproportionate impact.
AI can now generate screens in seconds. What AI does not do well is polish the last few milliseconds of touch feel — that is still yours to craft, one button at a time. Start with one today and let the rest of the app catch up over the following week.