Skip to content

feat(checkbox): modernize for md3 spec compliance#4966

Open
fabriziocucci wants to merge 1 commit into
callstack:mainfrom
fabriziocucci:feat/checkbox-md3-modernization-v2
Open

feat(checkbox): modernize for md3 spec compliance#4966
fabriziocucci wants to merge 1 commit into
callstack:mainfrom
fabriziocucci:feat/checkbox-md3-modernization-v2

Conversation

@fabriziocucci
Copy link
Copy Markdown
Contributor

Summary

Stacked on #4952 (token reorg + useFocusVisible hook).

Reviewers: because #4952 hasn't merged yet, the diff against main here also includes the 4 foundation commits from that PR (token reorg, shadow overrides, useFocusVisible, motion mass). The actual Checkbox change is just the final commit: 9ec676c. When #4952 lands, this PR rebases and the diff shrinks accordingly.

Bring the Checkbox component up to the Material Design 3 spec, end-to-end:

  • 18dp container with 2dp outline (unselected) → 0dp outline + theme primary fill (selected), inside a 40dp state-layer tap target.
  • State-layer overlay renders hover (8%), focus (10%) and pressed (10%) layers in the color the spec defines for each: selected → primary, unselected → onSurface, pressed-selected flips to onSurface, error always wins.
  • Focus indicator: 3dp ring at theme.colors.secondary with the 2dp outer-offset from md.sys.state.focusIndicator. Gated on :focus-visible via the useFocusVisible hook from refactor: improve structure of reference and system tokens #4952.
  • Animations approximate androidx.compose.material3.Checkbox.kt: 100ms fill transition + 150ms checkmark draw. The checkmark is sequenced short-leg then long-leg via scaleY to suggest the stroke fraction. Indeterminate uses a scaleX-animated dash.

Why not SVG

Compose Material3 draws the checkmark as a Path with strokeFraction 0 → 1. Doing the same in RN would mean adding react-native-svg as a Paper peer-dep, an ecosystem-level change. This PR uses a reveal-mask instead: a static L-shape (borderLeftWidth + borderBottomWidth rotated -45°) inside a left-anchored View whose width animates 0 → 18dp with a matching opacity fade. The visual suggests the stroke drawing left-to-right without the SVG dependency. Same precedent will apply to RadioButton when it's modernized. If you'd prefer the SVG-based approach for v6, happy to switch. Picking once now sets the precedent.

Files

  • src/components/Checkbox/Checkbox.tsx: full rewrite (Pressable 40dp tap target, state-layer overlay, animated 18dp container, focus ring, view-based checkmark + dash).
  • src/components/Checkbox/utils.ts: new getSelectionVisualState returns the full color + opacity + outline-width picture for any (selected × hovered × focused × pressed × disabled × error × customColor) combination. Legacy getSelectionControlColor kept as a back-compat export for RadioButtonAndroid.
  • 9 snapshots auto-updated to reflect the new render tree.

Out of scope

  • RadioButton modernization (separate PR with the same pattern).
  • Switch modernization (already in flight from refactor: improve structure of reference and system tokens #4952 onward).
  • Migrating animations from Animated to react-native-reanimated.
  • Extracting <StateLayer /> as a shared primitive (can follow once Radio + Switch land and the pattern is proven).

Test plan

  • yarn typescript clean
  • yarn lint clean
  • yarn jest: 736/737 (1 skipped, pre-existing); 159/159 snapshots pass (9 updated for the new renderer).

Visuals

Android iOS
checkbox-md3-android checkbox-md3-ios

Rewrites the Checkbox renderer to match the Material Design 3 spec
(https://m3.material.io/components/checkbox/specs):

- 18dp container with 2dp outline (unselected) / 0dp outline + theme
  primary fill (selected), inside a 40dp state-layer tap target.
- State-layer overlay renders hover (8%), focus (10%) and pressed (10%)
  layers in the color the spec defines for each (selected pressed flips
  to onSurface; error always wins).
- Focus indicator: 3dp ring at theme.colors.secondary with the 2dp
  outer-offset from md.sys.state.focusIndicator. Gated on
  :focus-visible via the useFocusVisible hook added in callstack#4952.
- Animations approximate Compose Material3 Checkbox.kt: 100ms fill
  transition and 150ms checkmark draw, sequenced short-leg then
  long-leg to suggest the stroke fraction. Indeterminate uses a
  scaleX-animated dash.
- No new peer-deps: the checkmark is built from two rotated rectangles
  (View-based), not an SVG path.

utils.ts:
- New getSelectionVisualState helper returns the full color +
  opacity + outline-width picture for a given state combo.
- Legacy getSelectionControlColor kept as a compatibility export
  for RadioButtonAndroid (radio button modernization is out of
  scope for this PR).

9 snapshots auto-updated to reflect the new render tree.
@fabriziocucci fabriziocucci force-pushed the feat/checkbox-md3-modernization-v2 branch from cee7317 to 2288d5d Compare May 26, 2026 14:19
@fabriziocucci fabriziocucci marked this pull request as ready for review May 26, 2026 14:25
Copilot AI review requested due to automatic review settings May 26, 2026 14:25
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR modernizes the Checkbox implementation to better align with the Material Design 3 checkbox spec, including updated visuals/animations and corrected accessibility behavior for the indeterminate state.

Changes:

  • Reworked Checkbox rendering to MD3-style layers (state layer, focus ring, outline/fill, glyph reveal mask) and moved away from icon-font rendering.
  • Introduced centralized visual-state resolution via getSelectionVisualState and updated color/state-layer logic.
  • Updated tests and snapshots, including ARIA-compliant checked: "mixed" handling for indeterminate checkboxes.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/components/Checkbox/Checkbox.tsx Reimplements Checkbox visuals/interaction using Pressable, MD3 tokens, and new animation/state-layer logic.
src/components/Checkbox/utils.ts Adds getSelectionVisualState to compute MD3 visual state (colors/opacities) and keeps legacy helper for RadioButton.
src/components/tests/Checkbox/CheckboxItem.test.tsx Updates accessibility-state assertions for indeterminate (“mixed” vs false).
src/components/tests/Checkbox/snapshots/Checkbox.test.tsx.snap Updates snapshots to match new Checkbox structure and accessibilityState output.
src/components/tests/Checkbox/snapshots/CheckboxItem.test.tsx.snap Updates snapshots to match new CheckboxItem structure and accessibilityState output.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +152 to 169
<Pressable
onPress={onPress}
onFocus={onFocus}
onBlur={onBlur}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
disabled={disabled}
accessibilityRole="checkbox"
accessibilityState={{ disabled, checked }}
accessibilityState={{
disabled,
checked: status === 'indeterminate' ? 'mixed' : status === 'checked',
}}
accessibilityLiveRegion="polite"
style={styles.container}
testID={testID}
theme={theme}
style={styles.tapTarget}
>
accessibilityRole="checkbox"
accessibilityState={{ disabled, checked }}
accessibilityState={{
disabled,
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.

2 participants