Skip to content

feat: fix residual md3 deviation - checkbox component - #5104

Open
likevy wants to merge 5 commits into
callstack:mainfrom
likevy:feat/fix-residual-md3-deviation-checkbox
Open

feat: fix residual md3 deviation - checkbox component#5104
likevy wants to merge 5 commits into
callstack:mainfrom
likevy:feat/fix-residual-md3-deviation-checkbox

Conversation

@likevy

@likevy likevy commented Sep 4, 2026

Copy link
Copy Markdown

Motivation

Checkbox is one of the MD3 reference components, but a review against the spec found residual deviations: the state-layer tokens were dead code, so every interaction fell back to TouchableRipple's neutral ripple; the touch target was 40dp; the focus ring sat on the state-layer edge; and a standalone checkbox had no accessible-name contract.

Things worth mentioning:

  • The checkbox paints its own press. MD3 draws the press as a ripple bounded to the 40dp state layer in the inverted role (Compose Checkbox.kt: ripple(bounded = false, radius = StateLayerSize / 2); material-web _checkbox.scss: md-ripple sized to the state layer). TouchableRipple can't get there: Android's ripple only accepts a numeric colour, so a dynamic theme's PlatformColor roles can never tint it; the ripple and iOS highlight fill the whole pressable, now 48dp; and on web its hover overlay doubles up with the layer. So the platform press is turned off (rippleColor: 'transparent', as Switch and BottomNavigation already do) and a 40dp ripple is drawn with Reanimated shared values — static colour, only opacity/transform animate, 200 ms grow with a 200 ms minimum hold so a quick tap still reads (Compose RippleAnimation, material-web MINIMUM_PRESS_MS). An explicit rippleColor or underlayColor hands the press back to the platform; rippleEffectEnabled: false still disables it.
  • The flat hover/focus layer fades by opacity only, keeping its colour static, because Reanimated cannot interpolate a PlatformColor

Visible behaviour changes, both in the migration guide: the checkbox occupies 48dp instead of 40dp, so rows get ~8dp taller; interaction tints follow selection instead of being neutral. A standalone Checkbox with no accessible name now warns.

Related issue

Checkbox review checklist:

  • Standalone checkbox touch target is 40dp (no default hitSlop); MD3 requires 48dp (the 40dp state layer itself already matches).
  • State-layer colours don't follow the per-state spec, which inverts on press — hover + focus: selected primary / unselected onSurface; pressed: selected onSurface / unselected primary. The lib applies one colour per selection state across all three interaction states (src/components/Checkbox/tokens.ts:24-25), so hover/focus are correct and pressed is wrong in both directions. Do not simply swap the two constants. Error states already match (error throughout).
  • Focus ring omits the 2dp outer offset.
  • Standalone Checkbox has no visible-label / whole-row accessible-name contract (Checkbox.Item is the labeled wrapper) — document and guarantee the standalone accessible-name path.

Test plan

yarn lint, yarn typecheck and yarn test pass — 738 tests, 168 snapshots. Each commit is independently green, so the series bisects cleanly.

60 new tests in Checkbox.test.tsx and utils.test.tsx cover the state-layer colour per interaction (custom colours and the pressed inversion included), the ripple on all three Platform.OS values, the minimum-press hold, the fade-out duration, the scale reset on a second press, reduce motion, the rippleColor/underlayColor escape hatches, rippleEffectEnabled: false, PlatformColor roles, the 48×48 target, the 50dp ring geometry and the accessible-name warning. Mutation-tested: removing the inversion, the hold, the scale reset, the fade duration or the custom-colour path each fails its test.

Manual, on the Checkbox example screen:

  1. Press and hold "Default" - a 40dp ripple grows inside the state layer: primary when unchecked, onSurface when checked. On Android with dynamic colour on it takes the system accent.
  2. Quick tap - the ripple still completes before fading; a rapid double tap doesn't leave one behind.
  3. "Error" - error ripple. "Custom color (tertiary)" - tertiary ripple, no primary halo.
  4. Tap just outside the visible 40dp - still toggles (48dp target); rows are ~8dp taller.
  5. Web: hover paints the flat layer in the non-inverted colour with no doubled overlay; Tab to a checkbox - 3dp secondary ring with a 2dp gap around the 40dp layer; a mouse click must not light the ring.
  6. Drawer → "Ripple effect" off - no press paint, hover and focus unaffected. Dynamic theme off - baseline primary.
  7. Screen reader - a standalone checkbox with aria-label announces its name and state; Checkbox.Item announces once for the row; one without a name logs the warning.
Screen.Recording.2026-09-04.at.16.50.46.mov
Screen.Recording.2026-09-04.at.16.51.11.mov
Screen.Recording.2026-09-04.at.16.51.40.mov

The state-layer tokens were declared but never read, so hover and focus had no
tint of their own. The layer now follows the spec -- `primary` when selected,
`onSurface` when not, `error` throughout an error checkbox, `color` and
`uncheckedColor` standing in for the role they override on the box -- and fades
by opacity alone, so a dynamic theme's `PlatformColor` roles are never
interpolated. Press handlers are only attached when something can handle a
press, since TouchableRipple keys its own disabled state off that.
MD3 paints the press as a ripple bounded to the 40dp state layer in the
inverted role -- `onSurface` when selected, `primary` when not. The platform
press could not deliver that: Android's ripple rejects the `PlatformColor` a
dynamic theme resolves roles to, iOS and Android only offered the neutral
default, and web's hover overlay doubled up with the layer. The checkbox now
draws the ripple itself and holds it for a minimum press so a quick tap still
reads. `rippleColor` or `underlayColor` hands the press back to the platform;
`rippleEffectEnabled: false` disables it as everywhere else.
The pressable was sized to the 40dp state layer, 8dp short of the minimum
interactive area, with no hitSlop making up the difference. Only the pressable
grows; the 40dp layers it centres stay where they were.
The ring sat on the state-layer boundary because the pressable clips overflow
to the tap-target shape and would crop the spec's outer offset. Rendered as a
sibling of the pressable it takes the spec geometry, which also covers Android
P+, where a foreground ripple forces the clip regardless of `borderless`.
A standalone checkbox renders no visible label, and `aria-label` reached it
only by inheritance, so it appeared in no prop table and nothing flagged one
that shipped unnamed. `Checkbox.Item` names the row and is exempt.
Copilot AI balanced review requested due to automatic review settings September 4, 2026 14:57

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.

🟡 Changes recommended

The implementation can change an active ripple’s color, double-render customized Android ripples, and break existing style layout semantics.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Updates Checkbox behavior to align with MD3 interaction, sizing, focus, and accessibility requirements.

Changes:

  • Adds MD3 state-layer and custom ripple behavior.
  • Expands touch targets and adjusts focus-ring geometry.
  • Adds accessibility guidance and comprehensive tests.
File summaries
File Description
src/components/Checkbox/utils.ts Resolves interaction colors and opacity.
src/components/Checkbox/tokens.ts Adds sizing and state-layer tokens.
src/components/Checkbox/Checkbox.tsx Implements interaction, focus, sizing, and accessibility behavior.
src/components/__tests__/Checkbox/utils.test.tsx Tests state-layer resolution.
src/components/__tests__/Checkbox/Checkbox.test.tsx Tests interactions, sizing, focus, and accessibility.
src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap Updates Checkbox.Item snapshots.
src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap Updates Checkbox snapshots.
docs/6.x/docs/guides/migration.md Documents behavioral and sizing changes.
Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +217 to +218
const platformOwnsPress =
rest.rippleColor != null || rest.underlayColor != null;
Comment on lines +451 to +453
// The ring is a sibling of the pressable, not a child: a foreground ripple
// forces `overflow: hidden` on it regardless of `borderless`.
<View style={styles.root}>
Comment on lines +484 to +487
style={[
styles.stateLayer,
{ backgroundColor: pressRipple.color },
rippleStyle,
@likevy
likevy requested a review from satya164 September 4, 2026 15:39
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.

2 participants