Skip to content

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

Open
k0ndee wants to merge 11 commits into
callstack:mainfrom
k0ndee:@k0ndee/48dp-touch-targets
Open

k0ndee wants to merge 11 commits into
callstack:mainfrom
k0ndee:@k0ndee/48dp-touch-targets

Conversation

@k0ndee

@k0ndee k0ndee commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Touch targets used to match the drawn box exactly: Checkbox 40×40, RadioButtonAndroid/RadioButtonIOS ~36×36 (20dp glyph + 8dp margin on Android, 24dp glyph + 6dp padding on iOS), IconButton 40×40, Chip's close icon 26×18.

MD3 instead grows the target around the component without resizing it, and only when the component is interactive - so a 40dp state layer stays 40dp visually but gains slop around it.

Each interactive component now computes its own hitSlop from its own fixed render-time size, via a new getMinInteractiveSizeHitSlop({ width?, height? }) utility. It returns the Insets needed to reach the 48dp minimum, or undefined if the component is already big enough. TouchableRipple itself does no measuring, it honors whatever hitSlop it's given. A caller-supplied hitSlop overrides the computed default, and a disabled component gets no slop at all.

This normalizes RadioButtonAndroid/RadioButtonIOS onto the same 40dp state layer as Checkbox/Switch (RadioButtonTokens.stateLayerSize), with glyph size/padding adjusted to stay centered. New per-component token files (Chip/tokens.ts, RadioButton/tokens.ts) hold the MD3 dimensions these computations derive from. The 48dp minimum itself is a local MIN_INTERACTIVE_SIZE constant in getMinInteractiveSizeHitSlop.ts - an earlier revision added it as a shared minInteractiveSize token on theme/tokens/sys/state.ts, but that was reverted per review feedback.

Two platforms, because one mechanism doesn't cover both:

  • Native - the computed Insets pass straight through to Pressable's hitSlop, same as a caller-supplied one.
  • Web - react-native-web dropped hitSlop support in 0.13.0, so TouchableRipple instead renders an aria-hidden, absolutely-positioned sibling sized from the same Insets, for the browser to hit-test. It renders before the children so it can never cover an interactive child. Switch doesn't go through TouchableRipple, so it carries its own equivalent webTouchTarget view.

A parent with overflow: 'hidden' clips the expanded target - which is why IconButton's hitSlop had never actually applied. So on web, the ripple now clips itself via its own inset, radius-matched container, rather than relying on the touchable or an ancestor.

That change pulls in a few more:

  • IconButton's container drops overflow: 'hidden'; the radius moves to the overlay and touchable so they clip themselves. Its hardcoded hitSlop ({10}/{6} depending on TouchableRipple.supported) is replaced by the computed one. Since shape can no longer be read out of style (it may be an animated value on the UI thread, invisible to synchronous StyleSheet.flatten), IconButton now also accepts borderRadius/borderTop*Radius/etc. as plain props, so callers needing a custom shape (e.g. segmented ends) can apply it directly to the self-clipping overlay/touchable.
  • SegmentedButtonItem now computes its own default hitSlop from content height (2 * paddingVertical + iconSize, never shorter than the label) - previously it only forwarded a caller-supplied hitSlop, with no floor of its own.
  • The press underlay on iOS and older Android now takes the touchable's radius - it was square before, and only looked right because a parent clipped it.
  • Chip's close button now fills the 34dp column the chip already reserved, instead of just the 26×18 icon.

Related issue

Follow-up to #5080 (fix: expand interactive targets to the 48dp minimum) - same change, rebased onto current main and updated per that PR's review feedback (the onLayout-based measurement was replaced with the static per-component computation described above).

Closes #5079.

Touches the same files as #5071 (fix: don't expose handler-less TouchableRipple as a disabled control), still open - no conflict today, but whichever lands second needs a look.

Button has the same clipped, non-applying hitSlop but is intentionally left unfixed here. Per review discussion on #5080, unclipping it is deferred to a follow-up on #5097 (Button MD3) once this merges. Both PRs touch __snapshots__/Button.test.tsx.snap; whichever lands second regenerates it.

Test plan

Lint, typecheck, and tests pass. A new TouchableRippleWeb.test.tsx pins the web target's style/ordering against the touchable's own bounds, a caller-supplied hitSlop, disabled, and the no-handler case.

The suite only renders a tree with no layout or hit testing, so it pins props, not behavior. Verified on device by tapping inside the expected slop and again past it:

iOS 18.3 Android 15 web
Checkbox 40dp 48 48 48
RadioButton 40dp 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

Notes

  • Chip's behavior changes: the right 34dp now fires onClose where it used to fire onPress. This matches MD3, where the primary action stops where the trailing action begins.
  • borderless no longer clips content on web. It still clips the ripple, now via its own inset container instead of the touchable itself - the touchable can't clip without also clipping the touch target. Nothing in Paper depended on the old behavior (checked across 569 touchables on 15 screens); prop docs updated.
  • Targets can now overlap, which is the MD3 default. On web, the later sibling wins the shared strip; reserve separate space instead if you'd rather avoid that.

Videos

Visual confirmation that TouchableRipple itself is unchanged before/after - only the hitSlop/touch target changed, not how the ripple looks or animates. Each pair is the same interaction recorded before and after this PR on the same platform.

Web

web_before.mov
web_after.mov

Android

android_before.mov
android_after.mov

iOS

ios_before.mov
ios_after.mov

@k0ndee
k0ndee force-pushed the @k0ndee/48dp-touch-targets branch from ad60f24 to f7b7f18 Compare September 8, 2026 08:24

@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.

Copied from my review on #5080. The layout-measurement feedback below has already been addressed in this PR. The remaining inline comments are copied unchanged.

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.

The following original Chip comment is also already addressed here, since the constants now precede the component JSDoc:

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

Comment thread src/theme/tokens/sys/state.ts Outdated
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.

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.

Trimmed to just the MD guidelines link, remove the implementation specific notes.

Comment thread src/theme/tokens/sys/state.ts Outdated
* 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

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.

Removed entirely. It is now a constant inside the getMinInteractiveSizeHitSlop.ts, not a shared token.

Comment on lines +341 to +346
{/* 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

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.

Removed

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

@satya164 satya164 Sep 9, 2026 •

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),
}
}
/>
)}

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.

Simplified along these lines, using a small inset() helper so the branches share the border-offset logic instead of duplicating it

<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

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.

Removed

Comment thread src/components/Checkbox/Checkbox.tsx Outdated
Comment on lines +82 to +85
// 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

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.

Reworded to explain the current rationale (why we stay inside the 40dp boundary) rather than referencing prior behavior.

* that, and those ancestors have to stop clipping to reach into the `hitSlop`.
*/
const getUnderlayShape = (style: StyleProp<ViewStyle>): ViewStyle => {
const flat = StyleSheet.flatten(style);

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.

StyleSheet.flatten needs to be removed. add explicit border radius props if needed similar to Surface

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.

Done, it's gone and replaced with explicit props.

: { top: 6, left: 6, bottom: 6, right: 6 }
}
testID={testID}
hitSlop={hitSlop}

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.

Codex: We need to account for joined controls when adding this default. ToggleButton.Row renders 42x42 buttons with no gap, and on web the right button's expanded target covers the last 3px inside the left button's visible box. Browser hit testing selects the right button in that strip. We need component-specific slop for joined buttons so pressing inside one option doesn't activate its neighbor.

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.

While working on ToggleButton.Row, I divided up hitSlop so adjacent buttons don't steal presses from each other on the shared edge, but that protection only applies to the horizontal Row layout.

When ToggleButton.Group is used bare, stacking buttons vertically (as shown in the "Group & enums" example), each button keeps its full, unshared hitSlop. Since there's no gap between them, pressing near the bottom edge of one button can activate the next one down instead. It is the same kind of overlap issue I fixed for Row, just not handled for vertical stacking.

Since Group is public API and documented to work without Row, this is reachable by anyone stacking toggle buttons vertically, not just in the example.

Options I see:

  • Add a ToggleButton.Column — mirrors Row's existing logic, just for top/bottom instead of left/right
  • Make ToggleButtonGroup orientation-aware so it handles this safely by default, without a new component

Which direction would you like for this PR, or should it be handled separately from the 48dp work?

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.

After private chat we decided to remove any changes to ToggleButton components. So my question was resolved :)

const buttonSize = size + 2 * PADDING;
const borderWidth = mode === 'outlined' && !selected ? 1 : 0;

const shapeStyles = {

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.

Codex: We still accept corner radii through style, but the overlay and touchable now only get the radii from these props. With style={{ borderRadius: 0 }}, the disabled contained button's fill changes from square to circular. ToggleButton and ToggleButton.Row also still pass their corners through style, so their press effects get the wrong shape. We need one source for the shape and need to update the existing callers if we're moving the radii to explicit props.

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.

Similar to above, we decided to not interfere with ToggleButtons and all other consumers already use the explicit borderRadius prop.

// slop of its own, only what a caller's own `hitSlop` in `rest` supplies.
const hitSlop = disabled
? undefined
: getMinInteractiveSizeHitSlop({ width: buttonSize, height: buttonSize });

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.

Codex: We're calculating slop from the outer default size, but the touchable can be smaller. With the default outlined button, the 1px border leaves a 38x38 touchable, so 4px of slop only reaches 46x46. TextInput.Icon also passes a 24x24 style, which gives us a 32x32 target with this calculation. Both sizes were verified in the browser. We need to account for the rendered component dimensions and border when choosing the default slop.

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.

Fixed generally: touchableWidth/touchableHeight now derive from the border-adjusted, explicit-override-aware rendered size, not the outer default. Verified both cases you cited: the default outlined button (38×38 touchable) now reaches the full 48dp, and TextInput.Icon's 24dp icon computes off its actual 40×40 render.

return typeof hitSlop === 'number'
? {
position: 'absolute',
top: inset(hitSlop),

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.

Codex: These absolute offsets start inside the touchable's border, so we don't expand from its outer bounds. With a 40x40 touchable, borderWidth: 4, and hitSlop={2}, we get a 36x36 target overlay. A point 1px outside the visible button misses in the browser, though it should be within the requested slop. We need to account for the border when positioning the web target.

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.

Fixed, the web target now offsets by the touchable's borderWidth prop and hitSlop

Comment thread src/components/Checkbox/Checkbox.tsx Outdated
disabled={disabled}
{...accessibilityProps}
testID={testID}
hitSlop={rest.hitSlop ?? (disabled ? undefined : CHECKBOX_HIT_SLOP)}

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.

Codex: With ??, we replace an explicit hitSlop={null} with the computed default. TouchableRipple treats null as no slop, so we're losing the caller's override here. The same issue exists in both RadioButtons, Chip, and SegmentedButtonItem, while IconButton preserves null. We should only apply the default when hitSlop is undefined.

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.

Fixed, switched to !== undefined so an explicit null override is preserved. Applied the same fix to both RadioButtons, Chip, and SegmentedButtonItem

Comment thread src/components/Chip/Chip.tsx Outdated
* 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

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.

Codex: The Material Web reference doesn't support this exception. Its remove button renders a .touch element, and that element has 48px height. The 24x24 dimensions in _trailing-icon.scss are for the ripple and focus ring. Here we leave the close target unexpanded and give the strips above and below it to the body, so a tap near the close icon can activate the chip instead. We need vertical expansion for the close action and a matching division of the two targets.

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.

Fixed, the close button now gets the same vertical-only slop as the body so it reaches the 48dp without the two targets overlapping

@k0ndee
k0ndee force-pushed the @k0ndee/48dp-touch-targets branch from 271d56a to 8910723 Compare September 10, 2026 10:41
@satya164
satya164 requested a balanced review from Copilot September 15, 2026 20:32

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

lukemorawski and others added 9 commits September 16, 2026 10:28
The component doc comment ended up separated from `const Chip =` by the
hitSlop helper constants, so the docs generator could no longer find it.
main removed the hardcoded default testIDs from Chip and IconButton
(callstack#5088). Guard Chip's close-icon testID the same way its container
already is, and pass explicit testID props in the hitSlop/close-icon
tests that relied on the old defaults.
@k0ndee
k0ndee force-pushed the @k0ndee/48dp-touch-targets branch from c53d39b to d3671d7 Compare September 16, 2026 08:32
@JKobrynski

Copy link
Copy Markdown
Collaborator

We've decided Checkbox, Switch and RadioButton should just be 48dp instead of getting an invisible tap area. Could you drop the hit-slop from Checkbox and Switch, keeping Checkbox's borderRadius? The RadioButton changes can go, since #5007 rewrites those files. Sorry for the turnaround.

@k0ndee

k0ndee commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

We've decided Checkbox, Switch and RadioButton should just be 48dp instead of getting an invisible tap area. Could you drop the hit-slop from Checkbox and Switch, keeping Checkbox's borderRadius? The RadioButton changes can go, since #5007 rewrites those files. Sorry for the turnaround.

@JKobrynski I pushed a commit to address that:

  • removed hit-slop from Checkbox and Switch
  • kept Checkbox's borderRadius
  • reverted the RadioButton changes

One thing I'd like to clarify: with the hit-slop gone, Checkbox's touch target is now just 40x40 layout, so it's smaller than 48dp. Switch and RadioButton are also at their current sizes. Should I make them 48dp in this PR or will that be handled in other PRs, like #5007 for RadioButton?

@satya164

satya164 commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Review by Claude

iOS

1. Icon Button with corners set through style gets a round press highlight

Steps: open Icon Button, go to Custom, press and hold the square "eye" button (style={styles.square}) or the outlined heart (style={styles.differentBorderRadius}).

What happens: the press highlight is a 40dp circle inside the square or custom-shaped button.

What should happen: the highlight takes the button's visible shape, as it did on main where the container clipped it.

iOS: round highlight inside square and custom-radius IconButtons

(via Claude, verify if it makes sense)

2. width and height on IconButton break the layout

Steps: in IconButtonExample.tsx, add <IconButton icon="camera" mode="outlined" width={20} height={20} onPress={() => {}} /> (local edit only), then press and hold it.

What happens: the touchable is 20dp wide and pinned to the left edge of the 40dp button, and it stays full height because styles.touchable has flexGrow: 1. The icon is cut off. The hit slop is computed for 20x20, so it adds 14dp above and below a touchable that is already about 38dp tall.

What should happen: the button content stays centered and the slop matches the rendered size.

iOS: IconButton with width and height 20, highlight pinned left and icon clipped

(via Claude, verify if it makes sense)

Web

3. Same IconButton shape problem on hover

Steps: open Icon Button, go to Custom, hover the square "eye" button.

What happens: the hover fill is a circle inside the square button.

What should happen: the hover fill matches the button shape.

Web: round hover fill inside the square IconButton

(via Claude, verify if it makes sense)

@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.

Review by Claude

The PR description no longer matches the code after the last commit. It still describes hit slop on Checkbox, RadioButton and Switch, the RadioButton tokens file, the Switch web target and the Checkbox and RadioButton rows in the test table. Please update it so the squash message is right. (via Claude, verify if it makes sense)

Comment on lines +219 to +237
- Corner radius and border width set through `style` no longer shape the highlight underlay (native) or the ripple's self-clipping container (web). Pass them as dedicated props instead:
- `borderRadius`
- `borderTopLeftRadius`
- `borderTopRightRadius`
- `borderBottomLeftRadius`
- `borderBottomRightRadius`
- `borderTopStartRadius`
- `borderTopEndRadius`
- `borderBottomStartRadius`
- `borderBottomEndRadius`
- `borderWidth` (web only)

e.g.:

```diff
<TouchableRipple
borderless
- style={{ borderRadius: 8 }}
+ borderRadius={8}

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 entry does not match the code. On web, TouchableRipple.tsx throws the radius props away, and the ripple container and hover fill read the radius from the touchable's computed style. On Android, android_ripple is clipped by the touchable's overflow: 'hidden' and the radius in style, and the props are not read. Only the iOS highlight underlay uses them.

So a user who follows this diff and moves borderRadius out of style gets a square hover and ripple on web and a square ripple on Android. Every caller in this PR keeps the radius in style and passes the props too. If the props stay, the entry should say to keep the radius in style, and that the props only shape the iOS press highlight. See the comment on TouchableRipple.native.tsx about whether the props are needed. (via Claude, verify if it makes sense)

Comment on lines +38 to +52
borderRadius?: number;
borderTopLeftRadius?: number;
borderTopRightRadius?: number;
borderBottomLeftRadius?: number;
borderBottomRightRadius?: number;
borderTopStartRadius?: number;
borderTopEndRadius?: number;
borderBottomStartRadius?: number;
borderBottomEndRadius?: number;
/**
* Web-only: widens the touch target back out past the touchable's own
* border. Accepted here too so both platforms share one `Props` type; has
* no effect on native, where `hitSlop` isn't offset from inside the border.
*/
borderWidth?: number;

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.

Are these props needed? Every caller in this PR (Button, Checkbox, Chip, DrawerItem, the FAB menu item, IconButton, SegmentedButtonItem) passes borderless, and its touchable style already has the same radius. With borderless, the iOS touchable gets overflow: 'hidden', so it already clips the underlay to that radius.

I checked this on the iOS simulator. With {...shapeStyles} removed from IconButton's TouchableRipple, the press highlight on the outlined button stayed round after a full reload. borderWidth has no caller outside the tests.

Dropping these 10 props removes the second source for the shape, the migration entry and the prop spreads in the callers (Checkbox's borderRadius too, since styles.tapTarget sets the same radius). A square underlay on a touchable without borderless is not related to the 48dp target and can go in a separate PR. (via Claude, verify if it makes sense)

const borderWidth = mode === 'outlined' && !selected ? 1 : 0;

const shapeStyles = {
borderRadius: borderRadius ?? buttonSize / 2,

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 earlier reply on this line says the other consumers already use the borderRadius prop, but the example app does not. example/src/Examples/IconButtonExample.tsx still passes corners through style (styles.square, styles.slightlyRounded, styles.differentBorderRadius), and style still accepts them. On main the container clipped everything to that shape. Now the touchable, the disabled overlay and the web ripple use the default circle while the border and background keep the style shape.

iOS, press and hold:

iOS: round highlight inside square and custom-radius IconButtons

Web, hover:

Web: round hover fill inside the square IconButton

Please move the example to the new props and add an IconButton entry to the migration guide that says corner radii in style no longer shape the press effect. (via Claude, verify if it makes sense)

Comment on lines +79 to +86
/**
* Width of the button's inner content. Defaults to the button's size.
*/
width?: number;
/**
* Height of the button's inner content. Defaults to the button's size.
*/
height?: number;

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.

width and height don't work as documented. They go into the touchable's style next to flexGrow: 1, so height has no effect and the touchable fills the button height. width makes the touchable narrower and pins it to the left edge, so the icon is cut off. The hit slop is still computed from the requested size, so width={20} height={20} adds 14dp above and below a touchable that is about 38dp tall.

iOS, <IconButton icon="camera" mode="outlined" width={20} height={20} /> pressed (local edit of the example):

iOS: IconButton with width and height 20, highlight pinned left and icon clipped

Nothing in the library or the example uses these props, and the project rules prefer style over new props. I'd drop width, height and the { width, height } style, compute the slop from buttonSize - 2 * borderWidth, and keep the old contentStyle description. A caller that makes the button bigger through style (like slightlyRounded) only gets extra slop, which does no harm. (via Claude, verify if it makes sense)

Comment on lines +250 to +252
// 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.

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 comment is not accurate. IconButton's container is an Animated.View, not a Surface, and this PR removes its overflow: 'hidden', so the container does not clip on web. It also describes the old code, which the earlier review asked to avoid.

Suggested change
// 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.
// Clip the ripple to the button shape. Not on web, where
// `overflow: 'hidden'` would also clip the hit slop target.

(via Claude, verify if it makes sense)


### Chip

The close button (`onClose`) now fills the entire trailing 34dp column reserved for it, matching Material Design 3's touch target guidance, instead of only its 24x18 icon. Taps near the top or bottom of that column, which used to fall through to the chip's own `onPress`, now activate `onClose` instead.

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.

On main the close Pressable was 26x18 (the 18dp glyph plus its 8dp marginRight), not 24x18, and this PR also adds 8dp of slop above and below it. Suggested wording:

Suggested change
The close button (`onClose`) now fills the entire trailing 34dp column reserved for it, matching Material Design 3's touch target guidance, instead of only its 24x18 icon. Taps near the top or bottom of that column, which used to fall through to the chip's own `onPress`, now activate `onClose` instead.
The close button (`onClose`) now fills the trailing 34dp column reserved for it, instead of only the 26x18 area around its icon, and its touch target extends to 48dp in height. Taps in that column that used to activate the chip's `onPress` now activate `onClose`.

(via Claude, verify if it makes sense)

Comment on lines +161 to +169
/**
* 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.
*
* Matches material-web's own remove button, which expands to a 48px touch
* target the same way; the 24x24 dimensions in its `_trailing-icon.scss` are
* for the ripple and focus ring, not the touch target.
* @see https://github.com/material-components/material-web/blob/main/chips/internal/_shared.scss
*/

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 comment repeats the review discussion about material-web's SCSS. The earlier review asked to keep only comments that explain unclear code. A short description is enough here.

Suggested change
/**
* 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.
*
* Matches material-web's own remove button, which expands to a 48px touch
* target the same way; the 24x24 dimensions in its `_trailing-icon.scss` are
* for the ripple and focus ring, not the touch target.
* @see https://github.com/material-components/material-web/blob/main/chips/internal/_shared.scss
*/
/**
* Width reserved on the right for the close button.
*/

(via Claude, verify if it makes sense)

Comment on lines +123 to +141
it('still calls a caller-supplied onLayout', async () => {
const onLayout = jest.fn();
await render(
<TouchableRipple
testID="touchable"
onPress={() => {}}
onLayout={onLayout}
>
<Text>Button</Text>
</TouchableRipple>
);

await act(async () => {
await fireEvent(screen.getByTestId('touchable'), 'layout', {
nativeEvent: { layout: { width: 32, height: 32, x: 0, y: 0 } },
});
});

expect(onLayout).toHaveBeenCalledTimes(1);

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 test is left over from the onLayout measurement that was removed. TouchableRipple no longer reads onLayout, so the test only checks that Pressable forwards its own prop. I'd remove it. (via Claude, verify if it makes sense)

@satya164

Copy link
Copy Markdown
Member

Review by Claude

Web

1. Chip close button focus ring covers the end of the label

Steps: open Chip, press Tab until the close button of the "Close button" chip has focus.

What happens: the browser draws the focus ring around the whole 34x32 close column. The ring cuts through the last letter of the label and goes past the top, bottom and right edges of the chip.

What should happen: the ring stays around the close icon, inside the chip, as on main.

Web: Chip close button focus ring on main and on this PR

(via Claude, verify if it makes sense)

2. Chip close icon touches the label in RTL

Steps: turn on RTL in the drawer, open Chip and look at a chip with a close button.

What happens: the close icon sits at the left edge of its column, right next to the label, and leaves 16dp of empty space on its right.

What should happen: the icon sits 8dp from the edge of the chip, as on main.

Web, RTL: Chip close icon position on main and on this PR

(via Claude, verify if it makes sense)

@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.

Review by Claude

See the inline comments. (via Claude, verify if it makes sense)

Comment on lines +208 to +209
const touchableWidth = width ?? buttonSize - 2 * borderWidth;
const touchableHeight = height ?? buttonSize - 2 * borderWidth;

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.

TextInput.Icon still gets a 32dp target. The earlier reply on the slop thread says it now computes off a 40x40 render, but TextInput passes width: 24 and height: 24 in the accessory style, and that style goes on the IconButton container. The touchable is 24x24, while this code computes the slop from the 40dp default and adds 4dp per side.

I tapped around the clear icon in a filled TextInput. On Android and iOS, a tap 3dp outside the icon fires onPress and a tap 5.5dp outside does not. On web the hit target element is 32x32.

IconButton can't see the size in style, so TextInputIcon could pass its own slop. IconButton spreads rest after its default, so this wins:

<IconButton
  hitSlop={getMinInteractiveSizeHitSlop({
    width: ACCESSORY_SIZE,
    height: ACCESSORY_SIZE,
  })}
  {...rest}

With this change on Android, a tap 11dp outside the icon fires and a tap 13.5dp outside does not. (via Claude, verify if it makes sense)

role="button"
aria-label={closeIconAccessibilityLabel}
testID={closeIconTestID}
style={styles.closeButton}

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.

On web, the browser focus ring now wraps this full 34x32 Pressable. It covers the last letter of the label and goes past the edges of the chip. On main the Pressable was only the icon box, so the ring stayed inside the chip.

Web: Chip close button focus ring on main and on this PR

One way to fix it is to do what Checkbox does: hide the outline on web and draw a ring around the glyph on focus. webNoOutline is a local constant there, so Chip needs its own. I tried this, and the ring stays around the icon inside the chip:

const [closeFocused, setCloseFocused] = React.useState(false);

<Pressable
  onFocus={() => setCloseFocused(true)}
  onBlur={() => setCloseFocused(false)}
  style={[styles.closeButton, Platform.OS === 'web' && webNoOutline]}
  ...
>
  ...
  <View style={[styles.icon, styles.closeIcon, styles.md3CloseIcon]}>
    {closeFocused && !disabled ? (
      <View
        pointerEvents="none"
        style={[styles.closeFocusRing, { borderColor: theme.colors.secondary }]}
      />
    ) : null}
    ...

closeFocusRing: {
  position: 'absolute',
  top: -3,
  left: -3,
  width: 24,
  height: 24,
  borderRadius: 12,
  borderWidth: 3,
},

(via Claude, verify if it makes sense)

Comment on lines 496 to +501
marginRight: 8,
padding: 0,
// `styles.icon` sets `alignSelf: 'center'`, which beats `alignItems` on the
// parent. Without this the glyph centres in the wider column and moves 4dp
// left.
alignSelf: 'flex-end',

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.

On web in RTL, the close column stays on the right, because react-native-web does not mirror right: 0. alignSelf: 'flex-end' follows the text direction, so it moves the glyph to the left edge of the column. The icon then touches the label and leaves 16dp of space on its right.

Web, RTL: Chip close icon position on main and on this PR

If the glyph is centered with no right margin, it sits 8dp from both sides of the 34dp column, so the direction does not matter. I checked this on web in LTR and RTL and on Android, and the icon lands where it is on main:

Suggested change
marginRight: 8,
padding: 0,
// `styles.icon` sets `alignSelf: 'center'`, which beats `alignItems` on the
// parent. Without this the glyph centres in the wider column and moves 4dp
// left.
alignSelf: 'flex-end',
marginRight: 0,
padding: 0,

In the 26dp minimum column the glyph then sits 4dp from each side, so the comment on CLOSE_AFFORDANCE_MIN_WIDTH needs a small update. (via Claude, verify if it makes sense)

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

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

5 participants