Skip to content

fix(button): prevent false ripples during scroll deceleration (#4911) - #5133

Open
Priyanshu0007 wants to merge 3 commits into
callstack:mainfrom
Priyanshu0007:fix/button-scroll-deceleration-ripple
Open

Priyanshu0007 wants to merge 3 commits into
callstack:mainfrom
Priyanshu0007:fix/button-scroll-deceleration-ripple

Conversation

@Priyanshu0007

@Priyanshu0007 Priyanshu0007 commented Sep 26, 2026 •

Copy link
Copy Markdown

Summary

This PR exposes the unstable_pressDelay prop on Button (forwarded to the underlying TouchableRipple / Pressable).

Motivation & Problem

When scrolling or flicking through a scrollable list (such as ScrollView or FlatList) containing buttons, touching the screen to stop deceleration can inadvertently trigger the button's ripple effect before the gesture responder transfers to the scroll container (responderTerminate).

Solution

Adding unstable_pressDelay allows configuring a brief delay (recommended 50-100ms) before the touch feedback is activated:

  • If a scroll gesture takes over (responderTerminate) within this delay window, no ripple effect is displayed.
  • Normal taps still fire onPress as expected.
  • Tagged as (Native-only) as react-native-web does not implement unstable_pressDelay.

Test Plan

  • Added unit tests in Button.test.tsx and TouchableRipple.test.tsx verifying:
    • Touch terminated before delay suppresses onPressIn and onPress.
    • Touch held beyond delay triggers onPressIn and onPress.
    • Fast taps shorter than delay defer onPressIn during touch and successfully trigger onPress on release.
  • Verified TypeScript checks and ESLint pass without warnings.

Related issue

Fixes #4911

Evidence media

iOS Media
button-scroll-before-ios.mp4
button-scroll-after-ios.mp4
Android Media
button-scroll-before-android.mp4
button-scroll-after-android.mp4
Web Media
button-scroll-before-web.mov
button-scroll-after-web.mov

@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Found potential problems with the pull request:

  • The description is too long. Please keep it under 1000 characters.

@MikitasK MikitasK left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good job 👍
the fix looks good: I reproduced the bug & fix, so the direction is right 💯

overall:

  • 2 things looks blocking to me: Build package CI step & test coverage
  • 1 coordination point: #5097 rewrites most of Button.tsx, so worth agreeing on sequencing before you spend more time here
  • the rest is discussion, not blocking

/**
* Delay in ms, from the start of the touch, before the press is engaged and ripple is triggered.
*/
unstable_pressDelay?: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we check how this lands against #5097 ? that PR is actively rewriting this file & already covers all 3 places this PR edits, so it'll definitely conflict.
since 6.x is moving Button to Expressive, what do you think about rebasing on it?

@MikitasK MikitasK Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker] another thing to flag: Build package CI step will fail for this PR (I reproduced it locally)

API leak guard in scripts/typescript-output-lint.ts sees unstable_pressDelay in generated Button.d.ts & flags it as unexpected RN prop.
Now that Button defines it, could we just remove it from unusedViewProps at scripts/typescript-output-lint.ts ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes total sense to coordinate with #5097. I'm happy to either rebase this branch directly onto feat/button-material-3-expressive now, or wait until #5097 lands in main and rebase then whichever makes review and merging easier for you. What's the current timeline for #5097?

expect(screen.getByTestId('active-button')).toBeEnabled();
});

it('renders button with unstable_pressDelay and triggers onPress', async () => {

@MikitasK MikitasK Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker] could we strengthen these?

I tried running this test after reverting changes in Button.tsx. Result - test passed, so seems like nothing here actually guards the delay.
userEvent.press resolves regardless of it. also RNL 14 removed UNSAFE_getByType that's why prop assertion isn't available either.

so wdyt about driving RN's responder state machine with fake timers asserting pressIn is suppressed on RESPONDER_TERMINATED?

Comment thread src/core/settings.tsx Outdated
testID,
}: IconProps) => React.ReactNode;
rippleEffectEnabled?: boolean;
ripplePressDelay?: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we keep this PR to per-component unstable_pressDelay prop & leave global ripplePressDelay for separate discussion?

#4911 is about a button inside scrolling list, but this setting changes every TouchableRipple-based component app-wide the moment someone sets it on PaperProvider.
it's also not fully global: Card uses its own Pressable with unstable_pressDelay={0} so pressable Card in a list would still misbehave.

for what it's worth, #3806 (same problem) was solved at the call site rather than with library-wide default. the wiring itself looks fine & consistent with rippleEffectEnabled - mainly wanted to keep this PR's surface area small

onPressIn={hasPassedTouchHandler ? handlePressIn : undefined}
onPressOut={hasPassedTouchHandler ? handlePressOut : undefined}
delayLongPress={delayLongPress}
unstable_pressDelay={unstable_pressDelay}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you think about fast-tap case?
with a delay set, tap shorter than it still fires onPress but with no visible feedback at all - close to the mirror image of original complaint.
could we document the trade-off on the prop? and/or suggest a smaller value maybe?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done,
I've added a note explaining this trade-off (that fast taps shorter than the delay will still trigger onPress but might not show the ripple animation) and recommended using small values (e.g. 50–100ms) to strike the right balance between scroll cancellation and touch feedback.

Comment thread src/components/Button/Button.tsx Outdated
*/
delayLongPress?: number;
/**
* Delay in ms, from the start of the touch, before the press is engaged and ripple is triggered.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] I tested it out & seems like press still fires - only feedback is delayed
so maybe we could reword it to smth like "...before the press feedback (ripple) is shown"?

@Priyanshu0007
Priyanshu0007 force-pushed the fix/button-scroll-deceleration-ripple branch from 1981b0c to 459e4a5 Compare October 31, 2026 19:59
@Priyanshu0007

Copy link
Copy Markdown
Author

Thanks a lot @MikitasK for the review!

I've addressed all your points dropped the global setting to keep the scope small, updated the prop docs with the fast tap note, added proper responder/timer tests, and rebased onto main to clear the conflicts.

Could you please take another look whenever you get a chance?

@MikitasK MikitasK left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the quick turnaround 🙏
earlier points are all addressed & code itself looks good to me 👍

there are just several blockers remaining:

  1. description is stale: it still advertises ripplePressDelay on PaperProvider/SettingsContext & TouchableRipple changes, which are gone.
    could you rewrite it around Button prop only?

  2. could you attach before/after media per platform (iOS, Android, web)?

  3. a few inline comments:


const button = screen.getByTestId('delay-button');

await fireEvent(button, 'responderGrant', createResponderEvent());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we add one more test for the most common case - quick tap that actually completes?

right now 2 tests cover:

  1. touch ends before the delay via responderTerminate -> no ripple, no press
  2. touch is held past the delay -> ripple shows, press fires

what's missing is in-between: user taps for less than the delay & lifts finger normally (responderRelease).

That's the everyday fast tap, and it's the case new docblock promises works: onPress fires, only ripple is skipped.
so could we add: grant -> advanceTimersByTime(60) -> responderRelease, then expect onPress to have been called & onPressIn not to have been called

/**
* Delay in ms, from the start of the touch, before the press feedback (ripple) is shown.
* Quick taps shorter than this delay will still fire `onPress` but might not show the ripple effect.
* Recommended values for buttons in scrollable lists are between 50ms and 100ms.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] could we tag this native-only, like background is marked "(Android)" above?
react-native-web doesn't implement unstable_pressDelay, so does nothing on web

@Doberjohn

Doberjohn commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

New props ship in 6.1.0, so I've put this on that milestone. #5097 rewrites Button.tsx, so please rebase once it's merged. @MikitasK's open points (the description, the media, the fast-tap test and the native-only note) can go in before that.

@Doberjohn Doberjohn added this to the 6.1.0 milestone Nov 3, 2026
@Priyanshu0007

Copy link
Copy Markdown
Author

Thanks for the feedback @MikitasK ! I've addressed all the remaining points and pushed the updates:

  1. Updated PR Description: Rewritten to focus strictly on Button's unstable_pressDelay (removed all mentions of SettingsContext and TouchableRipple).
  2. Before / After Media: Attached demonstration videos for Android, iOS, and Web (recorded with Argent).
  3. Point 3.1 (Fast tap test): Added the test case for completed quick taps in both Button.test.tsx and TouchableRipple.test.tsx.
  4. Point 3.2 (Docblock): Tagged unstable_pressDelay as (Native-only) in the JSDoc header.

Could you please take another look when you have a moment?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Buttons show false feedback when tapped during scroll deceleration

3 participants