npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 starts

This 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.0
  • react-native >= 0.73.0
  • react >= 18.0.0

Installation

# npm
npm install react-native-press-ripple

# yarn
yarn add react-native-press-ripple

# bun
bun add react-native-press-ripple

Then rebuild your native Android project — Expo autolinking picks up the module automatically:

npx expo run:android

Quick 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 trueonPressIn 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)  →  (  ○  )  →  fade

Best 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
       ^^   — Blue

Common 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: trueonPressIn 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

  1. usePressRipple holds ripple state: { x, y, trigger } stored in a useRef.
  2. On onPressIn — increments trigger counter, records locationX/locationY.
  3. ripple.View is a useCallback-cached component — only re-renders when trigger or config changes.
  4. RippleViewInner manages its own size via internal onLayoutno onLayout needed 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 180ms

Hardware 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 rect

Troubleshooting

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.gradle includes 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.json

License

MIT © milautonomos