In mobile app development, touch feedback isn’t just a nice-to-have — it’s fundamental to user experience. Every tap, press, and swipe should feel responsive and intentional. Yet most React Native apps suffer from inconsistent or missing feedback systems.
Why Good Feedback Matters
When users interact with your app, they expect immediate confirmation that their touch registered. This feedback comes in three forms:
- Visual feedback — Opacity changes, color shifts, or animations that show the touch was detected
- Haptic feedback — Physical vibrations that provide tactile confirmation
- Audio feedback — Subtle sounds that reinforce successful interactions
Without consistent feedback, users feel disconnected from your app. They may tap multiple times, wondering if their input registered, or lose confidence in your interface entirely.
The Pressable Limitation
React Native’s default Pressable component provides basic touch handling but leaves feedback implementation entirely up to developers. This leads to:
- Inconsistent experiences across different screens and components
- Repetitive code as developers manually implement the same feedback patterns
- Forgotten feedback in rushed development cycles
The result? Apps that feel fragmented and unpolished, despite having solid functionality underneath.
Introducing PressableWithFeedback
I created PressableWithFeedback as a wrapper around Pressable to handle consistent touch feedback in my apps.
Instead of manually implementing feedback systems across your app, this enhanced component provides four key features:
- Haptic Feedback Integration — A haptic prop that triggers customizable haptic feedback on each press, providing tactile confirmation to users
- Audio Feedback System — A soundSource prop that plays audio on each press, giving users auditory confirmation of their interactions
- Visual Opacity Feedback — An opacity prop that changes the component's opacity when pressed in and reverts back when pressed out, creating smooth visual feedback
- Smart Disabled State Handling — A disabledCallback prop that executes when users interact with disabled buttons, perfect for showing error messages or guidance
By wrapping Pressable with additional props and functionality, PressableWithFeedback eliminates repetitive code while ensuring every touch interaction feels polished and responsive. One component, unified experience.
Complete Implementation
Here’s the complete code for the PressableWithFeedback component:
import type { AVPlaybackSource } from "expo-av";
import type { ReactNode } from "react";
import React from "react";
import type { GestureResponderEvent, ViewProps } from "react-native";
import { Pressable, type PressableProps, StyleSheet } from "react-native";
import useSound from "@/hooks/use-sound";
import { type FeedbackType, useHaptic } from "@/hooks/useHaptics";
export type PressableWithFeedbackProps = PressableProps & {
children: ReactNode;
opacity?: number;
style?: ViewProps["style"];
haptic?: FeedbackType;
onPress?: null | ((event: GestureResponderEvent) => void);
soundSource?: AVPlaybackSource;
disabledCallback?: () => void;
disableOpacityChange?: boolean;
};
export const PressableWithFeedback: React.FC<PressableWithFeedbackProps> = ({
children,
opacity = 1,
style = {},
disabled,
haptic = "none",
onPress = () => {},
soundSource,
disabledCallback,
disableOpacityChange = false,
...rest
}) => {
const hapticFeedback = useHaptic(haptic);
const { playSound } = useSound({
audioPath: soundSource,
});
const handlePress = (event: GestureResponderEvent) => {
if (!onPress) {
return;
}
if (disabled) {
disabledCallback?.();
return;
}
hapticFeedback();
if (soundSource) {
playSound();
}
onPress(event);
};
return (
<Pressable
style={({ pressed }) => {
if (disableOpacityChange) {
return StyleSheet.compose(style, { opacity: 1 });
}
if (disabled) {
return StyleSheet.compose(style, { opacity: 0.5 });
}
if (pressed) {
return StyleSheet.compose(style, { opacity: opacity });
}
return StyleSheet.compose(style, { opacity: 1 });
}}
onPress={handlePress}
{...rest}
>
{children}
</Pressable>
);
};Understanding The Code:-
1. useHaptics — This is a hook which I have written to handle haptic feedback using expo-haptics. It takes in a FeedbackType and returns a function which when called gives me a feedback. Either you can use this or write your own implementation for haptics.
2. useSound — This is a hook which I have written to play sound using expo-av. It takes in a sound source and returns a function which when called plays the sound. Either you can use this or write your own implementation for sound.
4. opacity — Optional number that controls the opacity level when the component is pressed. Default value is 1. Lower values like 0.6 create more noticeable press feedback.
6. haptic— Optional FeedbackType that specifies what type of haptic feedback to trigger on press. Default is ‘none’. The available types depend on your useHaptic implementation.
8. soundSource — Optional AVPlaybackSource for audio feedback. Can be local files, remote URLs, or any source supported by expo-av.
9. disabledCallback — Optional function that gets executed when users press a disabled button. Perfect for showing error messages or user guidance.
10. disableOpacityChange — Optional boolean that prevents any opacity changes when set to true. Useful when you want custom press animations without opacity feedback.
Conclusion
Consistent press feedback is crucial for great UX — it makes your app feel responsive and fast, giving users confidence that every interaction is registered instantly. Without it, even the most feature-rich app can feel sluggish and unpolished.
PressableWithFeedback solves this by reducing complex feedback implementation to simple props. Instead of writing repetitive haptic, audio, and visual feedback logic across your app, you just pass haptic="light", soundSource={buttonSound}, or opacity={0.7} and you're done.
The result? Better user experience with better developer experience. Your users get consistent, responsive feedback everywhere, while you get clean, maintainable code with minimal effort.
useSound Gist:-
import type { AVPlaybackSource } from "expo-av";
import { Audio } from "expo-av";
import { useEffect, useState } from "react";
export default function useSound({
audioPath,
isLooping = false,
audioVolume = 1,
}: {
audioPath: AVPlaybackSource;
isLooping?: boolean;
audioVolume?: number;
}) {
const [audio, setAudio] = useState<Audio.Sound>();
useEffect(() => {
if (audio) return;
const loadAudio = async () => {
if (audioPath) {
const { sound } = await Audio.Sound.createAsync(audioPath, {
isLooping,
});
setAudio(sound);
}
};
loadAudio();
}, []);
useEffect(() => {
return audio ? unloadSound : undefined;
}, [audio]);
const playSound = () => {
audio && audio.setVolumeAsync(audioVolume);
audio && audio.playFromPositionAsync(0);
};
const stopSound = () => {
audio && audio.stopAsync();
};
const unloadSound = () => {
audio && audio.unloadAsync();
};
const muteSound = async () => {
const audioStatus = await audio?.getStatusAsync();
audio?.setIsMutedAsync(!audioStatus?.isMuted);
};
return { playSound, stopSound, unloadSound, audio, muteSound };
}useHaptics Gist:-
import type { AVPlaybackSource } from "expo-av";
import { Audio } from "expo-av";
import { useEffect, useState } from "react";
export default function useSound({
audioPath,
isLooping = false,
audioVolume = 1,
}: {
audioPath: AVPlaybackSource;
isLooping?: boolean;
audioVolume?: number;
}) {
const [audio, setAudio] = useState<Audio.Sound>();
useEffect(() => {
if (audio) return;
const loadAudio = async () => {
if (audioPath) {
const { sound } = await Audio.Sound.createAsync(audioPath, {
isLooping,
});
setAudio(sound);
}
};
loadAudio();
}, []);
useEffect(() => {
return audio ? unloadSound : undefined;
}, [audio]);
const playSound = () => {
audio && audio.setVolumeAsync(audioVolume);
audio && audio.playFromPositionAsync(0);
};
const stopSound = () => {
audio && audio.stopAsync();
};
const unloadSound = () => {
audio && audio.unloadAsync();
};
const muteSound = async () => {
const audioStatus = await audio?.getStatusAsync();
audio?.setIsMutedAsync(!audioStatus?.isMuted);
};
return { playSound, stopSound, unloadSound, audio, muteSound };
}