Unified Touch Experience for React Native

July 26, 2025 (3w ago)

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:

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:

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:

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 };
}