Skip to content

fix: expand interactive targets to the 48dp minimum - #5080

Closed
lukemorawski wants to merge 2 commits into
callstack:mainfrom
lukemorawski:feat/48dp-touch-targets
Closed

fix: expand interactive targets to the 48dp minimum#5080
lukemorawski wants to merge 2 commits into
callstack:mainfrom
lukemorawski:feat/48dp-touch-targets

Conversation

@lukemorawski

Copy link
Copy Markdown
Contributor

Motivation

Touch targets matched the drawn box: Checkbox 40x40, RadioButton 32x36,
IconButton 40x40, Chip close icon 26x18.

MD3 grows the target outside the component rather than resizing it, and only when the
component is interactive, so the 40dp state layer stays 40dp and gains slop around it.

Two mechanisms, since one does not cover both platforms:

  • native: TouchableRipple measures itself with onLayout and sets hitSlop
  • web: an aria-hidden absolutely positioned child at max(48px, 100%)

react-native-web removed hitSlop in 0.13.0, so web needs its own. An absolutely
positioned child is what material-web uses. Pseudo elements are not an option, a
::before is clipped like any other child.

Measuring and applying are separate. A component that mounts disabled gets no layout
event once it is enabled, since RN does not replay one, so gating the measurement on
interactivity would leave it small permanently.

A caller hitSlop wins on both platforms.

A parent with overflow: 'hidden' clips the expanded target. IconButton has been
shipping a hitSlop that never applied for this reason. So on web the ripple is now
clipped by its own container instead of by the touchable. Output is pixel identical.

That change pulls in:

  • IconButton's Surface drops overflow: 'hidden', and the radius moves to the
    overlay and the touchable so they clip themselves. Its own hitSlop is removed.
  • the press underlay on iOS and older Android takes the touchable's radius. It was
    square, and only looked right because a parent clipped it.
  • Chip's close button fills the 34dp column the chip already reserved, rather than
    just the 26x18 icon.

Related issue

Closes #5079

Touches the same file as #5071, which splits interactive from control. No conflict,
but whichever lands second needs a look.

Test plan

Lint, typecheck and tests pass.

The suite renders an element tree with no layout and no hit testing, so it only pins
props. Checked on device by tapping inside the expected slop and again past it.

iOS 18.3 Android 15 web
Checkbox 40dp 48 48 48
RadioButton 32x36 48 48 48
IconButton 40dp 48 48 48
past the slop miss miss miss
caller hitSlop wins ok ok ok
disabled gets nothing ok ok ok

Both run Fabric, and it applies from first mount without scrolling.

On Chip, the close button takes the right 34dp and the body the rest, on all three.

Notes

Chip changes behaviour. The right 34dp fires onClose where it fired onPress.
That matches MD3, where the primary action stops where the trailing one starts.

borderless no longer clips content on web. It still clips the ripple. The touchable
cannot clip without clipping the target. Nothing in Paper depends on it, checked
across 569 touchables on 15 screens. Prop doc updated.

Mount cost is up 1.6% on device, 165ms to 167.8ms for 300 touchables, three runs each
way, dev build. Not measurable in jest.

Targets can now overlap, which is the MD3 default. On web the later sibling takes the
shared strip. They can reserve space instead if you prefer.

Comment on lines +143 to +150
// A caller hitSlop wins, so there is nothing to measure for. `null` counts as
// supplied, it means "no slop".
const shouldMeasure = hitSlop === undefined;

// Gates whether the measurement is applied, not whether it happens. RN emits
// onLayout on mount and on layout change, so a touchable that mounts disabled
// gets no event once it is enabled and would stay small.
const shouldExpand = shouldMeasure && !disabled;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

could we keep measuring while custom hitSlop is set?

for example:

  1. component mounts at 32×32 with hitSlop={6}.
  2. caller removes it with hitSlop={undefined}.
  3. component should now calculate { top: 8, right: 8, bottom: 8, left: 8 }.
  4. currently it cannot, because const shouldMeasure = hitSlop === undefined; (line 145) disabled measurement while hitSlop={6}.
    adding onLayout afterward doesn't trigger new event unless the layout changes (RN docs](https://reactnative.dev/docs/view.html#onlayout))

so could what about removing shouldMeasure:

Suggested change
// A caller hitSlop wins, so there is nothing to measure for. `null` counts as
// supplied, it means "no slop".
const shouldMeasure = hitSlop === undefined;
// Gates whether the measurement is applied, not whether it happens. RN emits
// onLayout on mount and on layout change, so a touchable that mounts disabled
// gets no event once it is enabled and would stay small.
const shouldExpand = shouldMeasure && !disabled;
// Gates whether the measurement is applied, not whether it happens. RN emits
// onLayout on mount and on layout change, so a touchable that mounts disabled
// gets no event once it is enabled and would stay small.
const shouldExpand = hitSlop === undefined && !disabled;

and using handleLayout only in both Pressable's:

- onLayout={shouldMeasure ? handleLayout : onLayout}
+ onLayout={handleLayout}

I guess, this should keep latest measurement ready while still letting the caller-provided hitSlop win

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ah yeah, you are right and it does not even fall back to the caller value too. ends up with no slop at all. Tried with hitSlop={2}, droped it, and hitSlop is undefined 🤦
web is right, getTouchTargetStyle runs every render, so only native was wrong
Done: shouldExpand = hitSlop === undefined && !disabled, plus handleLayout on both Pressables, so this covers your other comments as well. Added a test for good measure. Thansk for that!

Cost is one extra measure and render on mount for touchables that do pass hitSlop, plus a render on every resize for a value that is never used. Nothing in Paper sets hitSlop internally any more, so it is only callers who ask for it. Fine by me :)

Comment thread src/components/TouchableRipple/TouchableRipple.native.tsx Outdated
Comment thread src/components/TouchableRipple/TouchableRipple.native.tsx Outdated
style={[
StyleSheet.absoluteFill,
{ backgroundColor, opacity: backgroundOpacity },
{ backgroundColor, opacity: backgroundOpacity, borderRadius },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

what about preserving custom corner styles like borderTopLeftRadius, borderTopRightRadius etc when moving clipping from Surface to TouchableRipple?

before this PR Surface’s overflow: 'hidden' clipped the ripple with a square top-left corner.
now only borderRadius is forwarded. so could we extract every border*Radius property & apply them to both new clipping layers?

so what about replacing lines 150 - 160 with smth like that:

const flattenedStyle = (StyleSheet.flatten(style) || {}) as ViewStyle;

const {
  borderWidth = mode === 'outlined' && !selected ? 1 : 0,
} = flattenedStyle;

const [, radiusStyles] = splitStyles(
  flattenedStyle,
  (key) => key.startsWith('border') && key.endsWith('Radius')
);

const {
  borderRadius = buttonSize / 2,
  ...cornerRadiusStyles
} = radiusStyles;

const shapeStyles = {
  borderRadius,
  ...cornerRadiusStyles,
};

const borderStyles = {
  borderWidth,
  borderColor,
  ...shapeStyles,
};

and then applying shapeStyles here:

Suggested change
{ backgroundColor, opacity: backgroundOpacity, borderRadius },
{ backgroundColor, opacity: backgroundOpacity, ...shapeStyles },

and in TouchableRipple :

<TouchableRipple
        borderless
        centered
        onPress={onPress}
        aria-label={ariaLabel}
        style={[
          styles.touchable,
          shapeStyles,
          // The Surface used to clip the ripple, so the touchable does it now.
          // Native only: its own overflow does not clip its hitSlop, but on web
          // it would clip the touch target, where the container already clips.
          Platform.OS !== 'web' && styles.clipToShape,
          contentStyle,
        ]}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeppers, this is a regression from the PR. looks like borderTopLeftRadius: 0 squares the container off and leaves the overlay and the ripple round
so i took splitStyles, which is what Surface, Card, CardCover and Button already do, but skipped the destructure since it spreads borderRadius back in:

  const shapeStyles = { borderRadius: buttonSize / 2, ...borderRadiusStyles };

same result, same as Card. Applied to the overlay and the touchable, so the other comment is covered too. mends web as well, ripple container copies corner radii (radiuses :D) off the touchable style.
Test added

Comment thread src/components/IconButton/IconButton.tsx Outdated

@MikitasK MikitasK left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM 🙏

@oleksandrzavarzin-callstack

Copy link
Copy Markdown
Contributor

Heads-up from #5097 (Button MD3): Button had its own version of this - a computed hitSlop on the inner TouchableRipple. And it never applied, because the touchable is wrapped by a view sized to the visual box with overflow: 'hidden'. Same shape as the IconButton case you describe. Removed it there rather than duplicate the mechanism.

Worth noting that the Button isn't covered here either: the onLayout does reach its touchable, but that ancestor swallows the slop, leaving extra-small (32dp) and small (40dp) under 48.
I'd rather not unclip Button in this PR, so I'll do it as a follow-up on my PR after this one merges.

Note: both PRs touch __snapshots__/Button.test.tsx.snap, so whoever lands second regenerates.

@satya164 satya164 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Remove LLM generated comments. Only add comments where the code maybe unclear and it's necessary.

The touchable should not enforce a minimum hitSlop. Adding unnecessary onLayout everywhere has performance overhead. It should only accept hitSlop prop without layout measurement or minimums. The web version only needs to implement hitSlop since React Native Web doesn't support it.

The actual hitSlop should be passed by components where they are needed, e.g. checkbox. It's simpler and doesn't have require layout measurements.

Comment on lines +172 to +190
/**
* Room the chip reserves on its right for the close button, which fills all of
* it, so the body stops here and the two divide the chip.
*
* MD3 splits the same way and does not give a chip's trailing action 48dp; in
* material-web it is 24x24 with no expansion. This column is wider than that and
* gets no vertical expansion, so the strips above and below belong to the body
* and a near miss activates the chip rather than deleting it.
* @see https://github.com/material-components/material-web/blob/main/chips/internal/_trailing-icon.scss
*/
const CLOSE_AFFORDANCE_WIDTH = 34;

/**
* Floor for the clamp below. The glyph is 18dp and sits 8dp from the right, so
* under this it hangs over the chip body, and part of the visible icon would
* activate the chip instead of removing it.
*/
const CLOSE_AFFORDANCE_MIN_WIDTH = 26;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These constants are added between the component and its JSDoc, which will break documentation generation for the component.

Comment on lines +19 to +21
* Minimum size of an interactive target. Applied by expanding outside the
* component's bounds, so it is separate from the 40dp state layer that
* Checkbox and Switch render.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Applied by expanding outside the component's bounds

This is an implementation detail the constant here can't possibly know or control

so it is separate from the 40dp state layer that Checkbox and Switch render

That's unnecessarily specific and the information doesn't belong here.

The comment should only contain link to MD guidelines, not implementation specific notes.

* Checkbox and Switch render.
* @see https://m3.material.io/foundations/designing/structure
*/
minInteractiveSize: 48,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

minInteractiveSize is not a state. so it shouldn't be here

Comment on lines +351 to +356
{/* Before the children, not after. It hit-tests, so as the last
sibling it covers anything interactive inside the touchable and
takes its presses, e.g. a pressable List.Item with a control in
`right`. Ahead of them it still covers the area outside the
touchable, where there is nothing else to hit.
Nothing that cannot be pressed gets a target, same as native. */}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the comment is unnecessary. everything it says is self-evident

Comment on lines +357 to +363
{!disabled && (
<View
aria-hidden
style={getTouchTargetStyle(hitSlop)}
testID="touchable-ripple-touch-target"
/>
)}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This could be simplified:

Suggested change
{!disabled && (
<View
aria-hidden
style={getTouchTargetStyle(hitSlop)}
testID="touchable-ripple-touch-target"
/>
)}
{!disabled && hitSlop != null (
<View
aria-hidden
style={
typeof hitSlop === 'number' ? {
position: 'absolute',
top: -hitSlop,
right: -hitSlop,
bottom: -hitSlop,
left: -hitSlop,
} : {
position: 'absolute',
top: -hitSlop.top ?? 0,
right: -hitSlop.right ?? 0,
bottom: -hitSlop.bottom ?? 0,
left: -hitSlop.left ?? 0,
}
}
/>
)}

<View
aria-hidden
style={getTouchTargetStyle(hitSlop)}
testID="touchable-ripple-touch-target"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

remove hardcoded test id

Comment on lines +83 to +86
// We don't apply `focusIndicator.outerOffset`, so the ring stays inside the 40dp
// circle. `TouchableRipple borderless` used to crop anything outside it; on web
// it no longer does, since the touchable cannot clip without clipping the touch
// target. Native still clips. Check both when revisiting the offset.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

what it used to is not relevant as a code comment

@satya164 satya164 closed this Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Touch targets fall below the 48dp minimum, and IconButton's hitSlop never applies

4 participants