react-native-press-ripple
v0.2.0
Published
Native Android press effect built with Expo Modules API. Multiple animation types: ripple, center, fade, flash. Zero JS-thread overhead, Canvas-based animation running entirely on native thread.
Downloads
651
Maintainers
Readme
react-native-press-ripple
Native Android press effect built with Expo Modules API.
Zero JS-thread overhead — animation runs entirely on the native Android thread via ValueAnimator + Canvas. No Reanimated, no Animated API, no bridge delays.
Why not Reanimated?
The standard approach with Reanimated still has a bottleneck:
onPressIn → JS setState → bridge → React render → mount Animated.View → useEffect → animation startsThis cycle takes 50–150ms on mid-range devices — visible as a "lag" before the effect begins.
react-native-press-ripple eliminates this:
onPressIn → JS sends trigger number → bridge → Kotlin triggerRipple() → ValueAnimator.start() → onDraw()Animation starts in < 1ms after the prop arrives on the native side.
Platform support
| Platform | Support | |----------|---------| | Android | ✅ Native Canvas + ValueAnimator | | iOS | — (no-op, renders nothing) |
Requirements
expo >= 50.0.0react-native >= 0.73.0react >= 18.0.0
Installation
# npm
npm install react-native-press-ripple
# yarn
yarn add react-native-press-ripple
# bun
bun add react-native-press-rippleThen rebuild your native Android project — Expo autolinking picks up the module automatically:
npx expo run:androidQuick start
import { Pressable, Text } from 'react-native'
import { usePressRipple } from 'react-native-press-ripple'
export const MyButton = () => {
const ripple = usePressRipple({
color: '#40000000', // black 25% opacity
borderRadius: 8,
})
return (
<Pressable onPressIn={ripple.onPressIn} style={styles.button}>
<Text>Press me</Text>
<ripple.View />
</Pressable>
)
}API
usePressRipple(config?)
The single export you need. Returns { onPressIn, View }.
const ripple = usePressRipple(config?)Config
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| color | string | '#40000000' | Effect color. Must be #AARRGGBB format (Android Color.parseColor). See color reference below. |
| borderRadius | number | 0 | Border radius of the parent container in dp. Used to clip the effect so it doesn't overflow rounded corners. Must match your button's borderRadius. |
| disabled | boolean | false | Explicitly disables the effect. When true — onPressIn is a no-op and View renders nothing. |
| animationType | 'ripple' \| 'center' \| 'fade' \| 'flash' | 'ripple' | Animation style. See Animation types below. |
Returns
| Key | Type | Description |
|-----|------|-------------|
| onPressIn | (event: GestureResponderEvent) => void | Pass to onPressIn of your Pressable / TouchableOpacity. |
| View | React.FC | Drop-in effect view. Place it as the last child inside your pressable container. Positions itself with absoluteFill automatically. |
Animation types
'ripple' (default)
Classic Material Design: a circle expands outward from the exact touch point, then fades out.
· ···
(touch) → ( ○ ) → fadeBest for: most buttons, list items, cards.
const ripple = usePressRipple({ animationType: 'ripple' })'center'
Same as ripple, but the circle always originates from the center of the container — regardless of where the user touched.
Best for: icon buttons, FABs, small tap targets where the touch point often misses the visual center.
const ripple = usePressRipple({ animationType: 'center' })'fade'
The entire container overlay fades in uniformly, holds briefly, then fades out. No expanding circle — full-area color wash.
Best for: subtle highlights, ghost buttons, toggle buttons, dark UI where ripple feels too intense.
const ripple = usePressRipple({
animationType: 'fade',
color: '#26000000', // lighter for fade style
})Timing: 120ms fade-in → 60ms hold → 200ms fade-out.
'flash'
Instant highlight — alpha jumps to max immediately (40ms), then quickly fades out (180ms). No expanding circle.
Best for: iOS-style press highlight feel, toolbar buttons, navigation items.
const ripple = usePressRipple({
animationType: 'flash',
color: '#26000000',
})Comparison table
| Type | Origin | Shape | Duration | Feel |
|------|--------|-------|----------|------|
| ripple | Touch point | Expanding circle | ~730ms total | Material Design |
| center | Container center | Expanding circle | ~730ms total | Material Design, centered |
| fade | Full area | Rectangle fill | ~380ms total | Subtle, uniform |
| flash | Full area | Rectangle fill | ~220ms total | Snappy, iOS-like |
Color format
Android's Color.parseColor() uses #AARRGGBB — note that alpha comes first, not last.
#AARRGGBB
^^ — Alpha (00 = transparent, FF = opaque)
^^ — Red
^^ — Green
^^ — BlueCommon values
| Color | Hex |
|-------|-----|
| Black 25% opacity | #40000000 |
| Black 15% opacity | #26000000 |
| White 45% opacity | #73ffffff |
| White 30% opacity | #4dffffff |
| Brand color 30% | #4d007AFF |
⚠️ Do not use CSS
rgba(0,0,0,0.25)format — Android will reject it and fall back to default color.
Usage patterns
Basic button
const ripple = usePressRipple({ color: '#40000000', borderRadius: 8 })
<Pressable onPressIn={ripple.onPressIn} style={styles.button}>
<Text>Submit</Text>
<ripple.View />
</Pressable>Primary (dark background) button — white ripple
const ripple = usePressRipple({
color: '#73ffffff', // white 45%
borderRadius: 10,
})Center ripple for icon button
const ripple = usePressRipple({
animationType: 'center',
color: '#26000000',
borderRadius: 24,
})iOS-style flash highlight
const ripple = usePressRipple({
animationType: 'flash',
color: '#26000000',
borderRadius: 8,
})Disabled state — ripple off automatically
const ripple = usePressRipple({
color: '#40000000',
disabled: Boolean(disabled), // pass your disabled prop
})When disabled: true — onPressIn returns immediately without updating state. No ripple renders. No extra conditional needed in your component.
Combining with your own onPressIn
const ripple = usePressRipple({ color: '#40000000', borderRadius: 8 })
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
ripple.onPressIn(event) // ripple first
myAnalytics.track('press')
},
[ripple.onPressIn],
)
<Pressable onPressIn={handlePressIn}>
...
<ripple.View />
</Pressable>Conditional ripple (e.g. by variant)
const ripple = usePressRipple({
color: variant === 'primary' ? '#73ffffff' : '#40000000',
borderRadius: BORDER_RADIUS[size],
disabled: variant === 'ghost', // no ripple for ghost buttons
})How it works internally
JS side
usePressRippleholds ripple state:{ x, y, trigger }stored in auseRef.- On
onPressIn— incrementstriggercounter, recordslocationX/locationY. ripple.Viewis auseCallback-cached component — only re-renders whentriggeror config changes.RippleViewInnermanages its ownsizevia internalonLayout— noonLayoutneeded on the parent.
Native side (Kotlin)
When the trigger prop changes:
triggerRipple(trigger)
→ checks animationType
→ 'ripple' / 'center':
converts dp → px
calculates maxRadius (distance to farthest corner)
startRippleAnimation()
AnimatorSet:
Phase 1: radius 0 → maxRadius + alpha 0 → target (80ms, PropertyValuesHolder)
Phase 2: radius continues → maxRadius (270ms)
Phase 3: alpha → 0 (250ms, 80ms delay)
each frame: invalidate() → onDraw()
canvas.clipPath(roundRect) ← respects borderRadius
canvas.drawCircle(x, y, r)
→ 'fade':
sets radius to cover full container
startFadeAnimation()
fade-in 120ms → hold 60ms → fade-out 200ms
→ 'flash':
sets radius to cover full container
startFlashAnimation()
flash-in 40ms → fade-out 180msHardware layer (LAYER_TYPE_HARDWARE) ensures GPU-composited rendering.
Architecture diagram
JS Thread Native Thread (Main)
───────────────────────────── ──────────────────────────────
onPressIn fires
setTriggerVersion(n)
↓
React re-render
→ RippleViewInner re-renders
→ NativePressRippleView props updated
↓
Bridge (single props batch) → triggerRipple(n)
ValueAnimator.start()
↓ every frame (~16ms)
onDraw(canvas)
clipPath (borderRadius)
drawCircle / fill rectTroubleshooting
Ripple doesn't show on Android
- Check that you rebuilt the native project (
npx expo run:android) after installing - Verify Expo autolinking picked up the module: check
android/settings.gradleincludes it
Ripple overflows rounded corners
Make sure borderRadius in config matches the actual CSS border radius of your container in dp:
// button has borderRadius: 8 in StyleSheet
const ripple = usePressRipple({ borderRadius: 8 })Color looks wrong / too dark or too light
Remember #AARRGGBB format. A common mistake:
// ❌ Wrong — CSS format, alpha at the end
color: '#00000040'
// ✅ Correct — Android format, alpha at the start
color: '#40000000'Ripple fires when button is disabled
Pass disabled to the hook:
const ripple = usePressRipple({
disabled: Boolean(disabled) || Boolean(rippleDisabled),
})The hook guards onPressIn internally — no extra if needed in your component.
Project structure
react-native-press-ripple/
├── src/
│ ├── index.ts # Public API: usePressRipple, RippleConfig, AnimationType
│ ├── PressRipple.tsx # Hook + RippleViewInner component
│ ├── ExpoPressRippleView.ts # requireNativeView bridge
│ └── types/
│ └── index.ts # AnimationType, RippleConfig, NativePressRippleProps
├── android/
│ ├── build.gradle
│ └── src/main/java/expo/modules/pressripple/
│ ├── ExpoPressRippleModule.kt # Expo Module definition, props registry
│ └── PressRippleView.kt # Native Canvas View + ValueAnimator, all animation types
├── expo-module.config.json
└── package.jsonLicense
MIT © milautonomos
