@rific/feedback-press
v0.13.1
Published
Haptic and sound feedback wrappers for React Native Paper and built-in pressable components
Maintainers
Readme
@rific/feedback-press
Haptic and sound feedback wrappers for React Native Paper and built-in pressable components. Drop-in replacements that fire selection haptics (and, optionally, an app-supplied sound) on touch-down and notification feedback on long press, with a single provider to toggle them globally.
Live demo: Sound plays on the web, but haptics and hit slop do nothing there, so the full behavior needs iOS or Android.
Install
npm install @rific/feedback-pressPeer dependencies:
# Required
npm install expo-haptics
# Optional, only needed to render the real Paper look
npm install react-native-paperreact-native-paper is never auto-detected and never a hard dependency of this package. Pass it to <FeedbackPressProvider paper={...}> (see below) and the Paper wrappers (Button, Card, FAB, etc.) render as real Paper components; omit it and they render a small built-in plain-RN fallback instead, so the package works the same whether you use Paper, a different styling library, or nothing at all. The exported Redux slice has no dependency on @reduxjs/toolkit: it works with RTK stores, vanilla Redux, or no Redux at all.
Usage
Without a provider (always-on)
The default context has haptics enabled, so the provider is optional if you don't need a toggle.
import { Button, Card, TouchableRipple } from '@rific/feedback-press'
export function MyScreen() {
return (
<Card onPress={() => openDetail()}>
<Card.Content>
<TouchableRipple onPress={() => doSomething()}>
<Text>Tap me</Text>
</TouchableRipple>
<Button onPress={() => submit()}>Submit</Button>
</Card.Content>
</Card>
)
}With a provider (user-controlled toggle)
Wrap once at your app root and pass the user's settings: every component inside reads them automatically.
import { FeedbackPressProvider } from '@rific/feedback-press'
import * as RNPaper from 'react-native-paper'
export function App() {
return (
<FeedbackPressProvider initialValue={{ vibrate: true }} onChange={saveSettings} paper={RNPaper}>
<RootNavigator />
</FeedbackPressProvider>
)
}paper is optional: pass it to get the real Paper look on the Paper wrappers, or omit it to use their plain-RN fallback.
Using the hook directly
import { useVibration } from '@rific/feedback-press'
export function DangerButton() {
const { notification, forceDouble } = useVibration()
return (
<Pressable
onPress={() => {
notification() // respects the provider toggle
deleteRecord()
}}
onLongPress={() => {
forceDouble() // always fires, ignores the toggle
wipeAll()
}}
/>
)
}withFeedback: wrapping your own components
The Paper and native wrappers below cover the common cases. For anything else (your own component, or one from a different styling library), withFeedback wires up the same haptic (and sound) behavior without a bespoke wrapper:
import { withFeedback } from '@rific/feedback-press'
import { Button } from 'some-other-ui-library'
const FeedbackButton = withFeedback(Button)
// <FeedbackButton onPress={...}> now fires `selection` on press-down and `notification`
// on long-press, exactly like this package's own Button.By default it wires selection to onPressIn (gated on onPress/onLongPress being present, so decorative elements stay silent) and notification to onLongPress, the same convention every component in this package follows. Pass a wiring map to target different props:
// A value-driven component (like Switch) instead of a press-driven one:
const FeedbackToggle = withFeedback(SomeToggle, { onValueChange: 'selection' })
// A component with no onPressIn (fires on release instead of touch-down):
const FeedbackFAB = withFeedback(SomeFAB, { onPress: 'selection', onLongPress: 'notification' })Components
All components are drop-in replacements with identical prop types to their originals. The Paper wrappers render the real react-native-paper component when paper is injected into <FeedbackPressProvider>, and a small built-in plain-RN fallback (not a Material Design reproduction) otherwise; see Install.
Paper wrappers
| Component | Fires on | Note |
|---|---|---|
| Button | onPressIn | |
| IconButton | onPressIn | |
| TouchableRipple | onPressIn | |
| Card | onPressIn | onLongPress has no event arg (Paper) |
| Chip | onPressIn | onLongPress has no event arg (Paper) |
| AppbarBackAction | onPress | Paper doesn't expose onPressIn |
| AppbarAction | onPress | Paper doesn't expose onPressIn |
| FAB | onPress | Paper doesn't expose onPressIn |
| Checkbox | onPress | Paper doesn't expose onPressIn |
| Switch | onValueChange | Value-driven, no onPress/onPressIn |
| SegmentedButtons | onValueChange | Value-driven, no onPress/onPressIn |
Card re-exports its subcomponents: Card.Content, Card.Title, Card.Actions, Card.Cover.
hitSlop on icon buttons
On plain react-native-paper, hitSlop on an IconButton does nothing: Paper puts the button's touchable inside a Surface with overflow: 'hidden', and React Native's hit testing never reaches past a clipping parent, so the touch area stays at the button's own box (40pt at the default size). Appbar.Action and Appbar.BackAction render an IconButton, so they have the same problem.
This package's IconButton, AppbarAction and AppbarBackAction make it work. Pass hitSlop (a number or Insets) and the touch area grows by that much past the button's edge, while the button looks the same at rest and while pressed:
// a 48pt target around a 40pt button
<IconButton icon='cog' hitSlop={4} onPress={openSettings} />With hitSlop set, the Paper button is wrapped in an invisible Pressable that owns the slop. The wrapper takes the button's placement styles (position, offsets, margins, flex, alignSelf, zIndex, display, transform) and Paper's own 6pt margin, so it sits exactly where the button did and layout doesn't change. A press inside the button still goes to Paper (with its ripple). A press in the slop band goes to the wrapper, which runs the same haptics, sound and callbacks but shows no ripple. The wrapper is hidden from screen readers and keyboard focus, and it's disabled whenever the button is disabled or has no handler. Without hitSlop, nothing is wrapped. Placement styles must be plain values, since the wrapper isn't an Animated component.
The other Paper wrappers (Button, Chip, FAB, TouchableRipple, Checkbox) don't clip, so Paper's own hitSlop already works on them. As with any hitSlop, the slop also stops at any ancestor with overflow: 'hidden'.
Native wrappers
| Component | Fires on |
|---|---|
| Pressable | onPressIn |
| TouchableOpacity | onPressIn |
| TouchableHighlight | onPressIn |
Feedback timing: selection fires on onPressIn (finger down) rather than onPress (finger up) to match native iOS feel. Long press fires notification on onLongPress. Elements with no onPress or onLongPress are treated as non-interactive and fire nothing.
FeedbackPressProvider
| Prop | Type | Default | Description |
|---|---|---|---|
| initialValue | Partial<HapticSettings> | defaultHapticSettings | Initial settings. Merged with defaults, partial is fine. |
| onChange | (settings: HapticSettings) => void | - | Called with the full settings object whenever settings change. |
| paper | PaperModuleShape | - | Injects react-native-paper (import * as RNPaper from 'react-native-paper') so the Paper wrappers render the real thing instead of their plain-RN fallback. Never auto-detected, never required. |
| sound | SoundConfig | - | Fires an app-supplied callback alongside each haptic. See Sound feedback. |
| soundInitialValue | Partial<SoundSettings> | defaultSoundSettings | Initial sound settings. Merged with defaults, partial is fine. |
| onSoundChange | (settings: SoundSettings) => void | - | Called with the full sound settings object whenever sound settings change. |
| children | ReactNode | - | |
type HapticSettings = {
vibrate: boolean // default: true
}useHapticSettings
Read or update settings from anywhere inside the provider:
import { useHapticSettings } from '@rific/feedback-press'
export function SettingsScreen() {
const { settings, set } = useHapticSettings()
return (
<Switch
value={settings.vibrate}
onValueChange={(value) => set({ vibrate: value })}
/>
)
}Redux integration
If your app uses Redux, wire the included slice to your store and bridge it to the provider:
import { configureStore } from '@reduxjs/toolkit'
import { hapticReducer, hapticActions, FeedbackPressProvider } from '@rific/feedback-press'
import { useSelector, useDispatch } from 'react-redux'
const store = configureStore({
reducer: {
haptic: hapticReducer,
// ...
}
})
export function App() {
const dispatch = useDispatch()
const settings = useSelector((state) => state.haptic)
return (
<FeedbackPressProvider
initialValue={settings}
onChange={(next) => dispatch(hapticActions.initialize(next))}
>
<RootNavigator />
</FeedbackPressProvider>
)
}Available actions: hapticActions.initialize(settings) (replace all), hapticActions.setVibrate(boolean).
Dispatching hapticActions or soundActions from anywhere in the app reaches the Provider because initialValue is live after mount: the Provider adopts any value that changes. That needs @rific/core 0.3.0 or later. With @rific/core 0.2.1 or older, initialValue is only read at mount, so write through the Provider's own setters instead.
useVibration
const {
// Semantic
selection, // () => void, light tap (iOS selectionAsync)
notification, // (type?) => void, success/warning/error pulse
// Impact
short, // () => void, light impact
medium, // () => void, medium impact
long, // () => void, heavy impact
double, // () => void, two-pulse notification
custom, // (duration: number) => void
// Force, bypass the enabled toggle
force, // (duration?: number) => void
forceShort, // () => void
forceMedium, // () => void
forceLong, // () => void
forceDouble, // () => void
isEnabled, // boolean, current provider state
} = useVibration()All methods respect the FeedbackPressProvider enabled flag. The force* variants bypass it: use them for feedback that should always fire (error states, destructive confirmations).
On iOS, methods use expo-haptics native APIs. On Android, they fall back to Vibration.vibrate() with mapped durations.
useHoldToRepeat
For a long-press-and-hold control that should keep firing for as long as it's held - a stepper FAB, a randomize button, anything with a "do it again, and again" gesture - rather than a single one-shot onLongPress. Fires action once immediately, then again every repeatMs for as long as the hold continues, alongside a selection/notification pulse (haptic and, if configured, sound) on every one of those firings, not just the first.
Component-agnostic: useHoldToRepeat just returns plain onLongPress/onPressOut callbacks, so it works with any component that exposes those two props - every wrapper in this package (Button, Card, Chip, FAB, IconButton, TouchableRipple, the native Pressable/TouchableOpacity/TouchableHighlight), a plain react-native-paper or bare-RN component, or your own via withFeedback. It's demonstrated below with FAB, but nothing about it is FAB-specific - including in this package's own plain-RN fallback (no paper injected): every wrapper that accepts onLongPress also correctly forwards onPressOut/delayLongPress in fallback mode, not just when Paper is real.
import { FAB, useHoldToRepeat } from '@rific/feedback-press'
export function StepperFAB({ onStep }: { onStep: () => void }) {
const hold = useHoldToRepeat(onStep, 400)
return <FAB icon='plus' onLongPress={hold.onLongPress} onPressOut={hold.onPressOut} delayLongPress={400} />
}const {
onLongPress, // () => void - wire to the component's own onLongPress
onPressOut, // () => void - wire to the component's own onPressOut
} = useHoldToRepeat(action, repeatMs, options?)| Option | Type | Default | Description |
|---|---|---|---|
| feedback | 'selection' \| 'notification' \| false | 'selection' | Which feedback event fires on every tick. false disables feedback for the whole repeat loop; action still fires normally. |
| hapticDisabled | boolean | false | Suppresses just the haptic, keeping any sound. |
| soundDisabled | boolean | false | Suppresses just the sound, keeping the haptic. |
| sound | SoundConfig | - | Overrides the provider's ambient sound config for just this hold-to-repeat loop, the same per-instance override every wrapper component's own sound prop already gives. |
useHoldToRepeatByKey is the keyed sibling, for a set of independently-holdable targets that all funnel through one action (e.g. per-item "randomize" buttons rendered from a list) instead of a single fixed one - same shape, but action takes a key, and onLongPress/onPressOut are curried on it. Two different keys held at once keep entirely separate timers; releasing one never stops the other's.
const hold = useHoldToRepeatByKey(randomizeGroup, 1000)
<FAB onLongPress={hold.onLongPress('mirror')} onPressOut={hold.onPressOut('mirror')} />Both hooks call action (and pulse feedback) synchronously on the initial onLongPress - pair with a component that already fires its own notification there (like this package's own FAB) and the very first press gets both; that's intentional, the same "confirmed, now repeating" double-pulse a real long-press-then-hold naturally reads as.
onLongPress is safe to call again before a matching onPressOut (e.g. a gesture handler that double-fires it) - it restarts the repeat cleanly instead of leaking the previous interval.
Sound feedback
Pass a sound config to <FeedbackPressProvider> to fire an app-supplied callback at the same instant a selection/notification haptic fires - a generic UI click sound, a success chime, whatever your app plays. It's entirely optional: omit sound and every component behaves exactly as before this prop existed, haptic-only.
import { FeedbackPressProvider } from '@rific/feedback-press'
export function App() {
return (
<FeedbackPressProvider sound={{ selection: playClick, notification: playChime }}>
<RootNavigator />
</FeedbackPressProvider>
)
}| Key | Fires alongside |
|---|---|
| selection | the selection haptic |
| notification | the notification haptic |
Both keys are optional: pass only the ones you have a sound for.
Playing the sounds: @rific/feedback-press/audio
sound just wants a () => void per event - how you build playClick/playChime is up to you. If you're using expo-audio, @rific/feedback-press/audio exports useAudioPool, a small hook that plays a clip through a round-robin pool of players instead of a single shared one:
npm install expo-audioimport { useAudioPool } from '@rific/feedback-press/audio'
import { FeedbackPressProvider } from '@rific/feedback-press'
const CLICK_SOUND = require('./assets/click.wav')
const CHIME_SOUND = require('./assets/chime.wav')
export function App() {
const playClick = useAudioPool(CLICK_SOUND)
const playChime = useAudioPool(CHIME_SOUND, { poolSize: 6 })
return (
<FeedbackPressProvider sound={{ selection: playClick, notification: playChime }}>
<RootNavigator />
</FeedbackPressProvider>
)
}Why a pool, not a single player: a single AudioPlayer retriggered rapidly races itself - a second press's seekTo(0) (needed so a retriggered clip restarts from the top instead of resuming mid-clip) can reset playback position out from under the first press's still-in-flight play() call, silently aborting it. The sound just never plays for that press, not merely overlaps or cuts short. useAudioPool builds the pool with createAudioPlayer (a plain factory, not the useAudioPlayer hook) and hands back a single play function; overlapping presses land on different players internally and never race each other.
Steady timing: each player rewinds to the start as soon as its clip finishes, so a trigger normally only calls play() and the sound starts a steady delay after it. Seeking on every trigger made evenly spaced triggers (a card game's deal, say) come out unevenly spaced, because a seek resolves at uneven times. A player triggered again before its rewind has landed (a pool too small for how fast it's triggered) still seeks first.
poolSize (default 4, clamped to 16) is a real allocation, not a fixed ceiling - poolSize: 1 creates exactly one native player, poolSize: 6 creates six. Pick it per clip based on how often that specific sound actually gets retriggered in quick succession: a one-shot sound (a win/loss jingle) is fine at the default or even 1, while something a user might rapidly double/triple-tap wants more headroom. Call useAudioPool once per clip, same as useAudioPlayer.
By default, the first useAudioPool mounted anywhere in the app also configures the shared expo-audio session for mixWithOthers playback (setAudioModeAsync({ interruptionMode: 'mixWithOthers', playsInSilentMode: false })), once per app lifetime no matter how many pools mount after it. On iOS that is the .ambient category: feedback sounds mix with whatever the user is already playing and go quiet when the Ring/Silent switch is on, the standard behavior for game and UI sounds. Without it, iOS leaves the session on its default .soloAmbient category, which stops whatever music or podcast the user already has playing and adds a session-negotiation delay before every feedback sound is actually heard. useAudioPool pays that cost once, up front, so short UI clips just play alongside whatever else is already going.
Pass configureAudioSession: false on a given pool to opt it out, for a host app that already manages setAudioModeAsync itself elsewhere - one that also does its own media playback or recording and wants a different session mode there. Because that configuration is a process-wide singleton rather than something scoped to one pool, the opt-out is process-wide too: once any pool mounts with configureAudioSession: false, no pool anywhere in the app - including ones that omit the option - will call setAudioModeAsync for the rest of the app's lifetime. It can only suppress configuration that hasn't happened yet, though, so it can't undo a call already made by a default (true) pool that mounted first; mount the opted-out pool as early as possible (e.g. alongside FeedbackPressProvider) if you're relying on it.
expo-audio is a peer dependency of this subpath only, not of the package's main entry - haptics-only consumers never need it installed, and Metro never traces it unless you import from @rific/feedback-press/audio yourself.
useGatedAudioPool
useAudioPool above always plays - it has no idea about useSoundSettings' enabled toggle, because the sound config passed to <FeedbackPressProvider> and to each wrapper's per-instance sound prop is already gated on it internally (see useFeedbackHandlers' fire). That gate doesn't reach gameplay/ambient SFX played outside that pipeline - a goal horn, a countdown beep, anything triggered directly from game logic rather than a Button/Pressable press. useGatedAudioPool is useAudioPool pre-wired to the same useSoundSettings toggle, for exactly that case:
import { useGatedAudioPool } from '@rific/feedback-press/audio'
const playGoalHorn = useGatedAudioPool(require('./assets/goal.wav'))
// Calling playGoalHorn() is a no-op while the user's sound setting is off - no need to check
// settings.enabled yourself before calling it.Same signature and options (poolSize, configureAudioSession) as useAudioPool - call once per clip, same as its ungated sibling.
useSoundSettings
A global sound-enabled toggle, analogous to useHapticSettings for vibration. When enabled is false, every sound callback (provider-wide and per-instance) is suppressed regardless of soundDisabled; haptics are unaffected.
type SoundSettings = {
enabled: boolean // default: true in production, false in dev/simulator builds (__DEV__)
}The default is dev-aware so local development and automated testing don't play sound unexpectedly - production builds are unaffected. If your app computes its own initial value (e.g. from persisted storage, as in the Redux integration below), mirror this same !__DEV__ fallback for a never-set/first-launch value rather than hardcoding true, so a fresh install in dev picks it up too.
Read or update the setting from anywhere inside the provider:
import { useSoundSettings } from '@rific/feedback-press'
export function SettingsScreen() {
const { settings, set } = useSoundSettings()
return (
<Switch
value={settings.enabled}
onValueChange={(value) => set({ enabled: value })}
/>
)
}Redux integration
If your app uses Redux, wire the included slice to your store and bridge it to the provider, the same way as hapticReducer/hapticActions:
import { configureStore } from '@reduxjs/toolkit'
import { soundReducer, soundActions, FeedbackPressProvider } from '@rific/feedback-press'
import { useSelector, useDispatch } from 'react-redux'
const store = configureStore({
reducer: {
sound: soundReducer,
// ...
}
})
export function App() {
const dispatch = useDispatch()
const settings = useSelector((state) => state.sound)
return (
<FeedbackPressProvider
soundInitialValue={settings}
onSoundChange={(next) => dispatch(soundActions.initialize(next))}
>
<RootNavigator />
</FeedbackPressProvider>
)
}Available actions: soundActions.initialize(settings) (replace all), soundActions.setEnabled(boolean).
useFeedbackBridgeProps: bridging both Redux slices at once
The two Redux integration examples above each wire one slice by hand. An app using both (the common case - most apps toggle haptics and sound together in one settings screen) ends up with a "FeedbackBridge" component that re-derives the same five-prop shape (initialValue, onChange, soundInitialValue, onSoundChange, sound) every time. useFeedbackBridgeProps collects them for you:
import { FeedbackPressProvider, hapticActions, soundActions, useFeedbackBridgeProps } from '@rific/feedback-press'
import { useAudioPool } from '@rific/feedback-press/audio'
import { useDispatch, useSelector } from 'react-redux'
const FeedbackBridge = ({ children }) => {
const haptic = useSelector((state) => state.haptic)
const sound = useSelector((state) => state.sound)
const dispatch = useDispatch()
const playSelection = useAudioPool(require('./assets/select.wav'))
const playNotification = useAudioPool(require('./assets/notification.wav'))
const bridgeProps = useFeedbackBridgeProps({
initialValue: haptic,
onChange: (next) => dispatch(hapticActions.initialize(next)),
soundInitialValue: sound,
onSoundChange: (next) => dispatch(soundActions.initialize(next)),
sound: { selection: playSelection, notification: playNotification }
})
return <FeedbackPressProvider {...bridgeProps}>{children}</FeedbackPressProvider>
}Takes plain values and callbacks rather than a dispatch function or any Redux-specific type - it has no opinion on your store's shape, or that you're using Redux at all. paper and children aren't part of its input: pass paper directly to <FeedbackPressProvider> alongside the spread bridgeProps, same as children.
Per-instance overrides
Every wrapper component accepts four optional, wiring-time-only props for tweaking just that one instance - none of them ever reach the underlying Paper/native component:
| Prop | Effect |
|---|---|
| soundDisabled | Suppresses just the sound callback on this instance, keeping its haptic. |
| hapticDisabled | Suppresses just the haptic on this instance, keeping its sound - the independent counterpart to soundDisabled. |
| sound | Overrides the provider's ambient sound config for just this instance, e.g. a distinct sound for one particular button. Same { selection?, notification? } shape as the provider's own sound prop. |
| exclusive | Fires exactly one of selection/notification per gesture instead of both - see below. |
// A silent-but-still-vibrating button:
<Button soundDisabled onPress={submit}>Submit</Button>
// A button that vibrates but never makes a sound, even with a provider-wide sound configured:
<IconButton icon='delete' hapticDisabled onPress={remove} />
// A button with its own distinct sound instead of the app-wide generic click:
<Button sound={{ selection: playDeleteSound }} onPress={remove}>Delete</Button>exclusive
By default, selection fires the moment a finger goes down (onPressIn, matching native iOS feel) and notification fires separately if the press escalates into a completed long-press - so a slow press-and-hold fires both, one after the other. That's the right feel for most buttons, but some presses want the opposite: a stepper's -/+, a delete action distinct from a delete-and-undo long-press, anything where the two feedback events represent mutually exclusive outcomes of the same gesture rather than an escalating pair.
<Button exclusive onPress={submit} onLongPress={submitAndClose}>Submit</Button>With exclusive, selection is deferred all the way to release and only actually fires if the press never escalated - notification alone fires for a completed long-press, never both. Your own onPress/onLongPress/onPressIn/onPressOut callbacks are unaffected either way; only the haptic/sound feedback's timing changes. Works on every wrapper that accepts onLongPress (Button, Card, Chip, FAB, IconButton, TouchableRipple), the native Pressable/TouchableOpacity/TouchableHighlight wrappers, and your own components via withFeedback - anywhere the underlying wiring maps some prop to notification. On a component with no long-press concept (Checkbox, Switch, SegmentedButtons, AppbarAction, AppbarBackAction), exclusive is a harmless no-op, since there's nothing to be exclusive against.
Usage with @rific/auto-paper
Paper wrappers automatically inherit the theme from @rific/auto-paper's Provider, no extra wiring needed.
import { Provider } from '@rific/auto-paper'
import { FeedbackPressProvider, Button } from '@rific/feedback-press'
export function App() {
return (
<Provider appearance="system" color="#FF6B6B">
<FeedbackPressProvider initialValue={{ vibrate }}>
<Button onPress={handlePress}>Themed + Haptic</Button>
</FeedbackPressProvider>
</Provider>
)
}Platform notes
- iOS: uses
expo-haptics(selectionAsync,notificationAsync,impactAsync) - Android: falls back to
Vibration.vibrate()with duration mapping - Web: haptics are no-ops (expo-haptics returns silently).
hitSlopis a no-op too: react-native-web ignores it on every component, so an icon button's target on web is its own box.
