feat(checkbox): modernize for md3 spec compliance#4966
Conversation
cee7317 to
2288d5d
Compare
There was a problem hiding this comment.
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
Checkboxrendering 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
getSelectionVisualStateand 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.
3c1cb09 to
6ce573a
Compare
|
Hey @fabriziocucci, thank you for your pull request 🤗. The documentation from this branch can be viewed here. |
ec70715 to
a775d07
Compare
|
Focus ring is now gone. Check #4957 review comments for how it was handled there. |
4ec40c5 to
dfe21d0
Compare
da62e1c to
64df4f8
Compare
|
adrcotfas
left a comment
There was a problem hiding this comment.
See previous comment.
64df4f8 to
1fe92ec
Compare
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.
1fe92ec to
7f47d89
Compare


Summary
Bring the
Checkboxcomponent up to the Material Design 3 spec, end-to-end:TouchableRipple(borderless + centered), matchingIconButton/Chip/Button. This gives the native Android ripple animation per @adrcotfas's review (the ripple color won't fully respect MD3 alpha until RN 86 shipsPlatformColor+ alpha forandroid_ripple, but the animation lands today).rippleColoris fed bygetSelectionVisualState({ ..., pressed: true })so the ripple honours the MD3 selected/unselected colour flip on press.theme.colors.secondarywith the 2dp outer-offset frommd.sys.state.focusIndicator. On web, the ring is gated on:focus-visiblevia the sharedisKeyboardFocusEventhelper (introduced in feat: modernize Switch to MD3 #4957) so it doesn't appear after a pointer press; on native it shows on any focus. The browser's default focus outline is suppressed via Adrian'swebNoOutline = { outline: 'none' } as unknown as ViewStylepattern from feat: modernize Switch to MD3 #4957.useLocale().direction === 'rtl'so the checkmark appears to draw from the leading edge. On native,I18nManageralready mirrors the layout so the defaultleft: 0anchor does the right thing automatically (matches the approach used in feat: modernize Switch to MD3 #4957). The glyph itself is not mirrored (Compose M3 keeps checkmark orientation across locales).<Checkbox>now emitsaccessibilityState.checked === 'mixed'forstatus="indeterminate"(per W3C ARIA for tri-state controls).Checkbox.Item's outer row no longer duplicates the'mixed'announcement — it defers to the inner Checkbox, so screen readers announce "mixed" once. Alsodisabledis now coerced via!!disabledto avoid leakingundefined.style?: StyleProp<ViewStyle>prop forwarded to the underlyingPressable, per Copilot's review (the rest of the additionalPressableprops —accessibilityLabel,hitSlop,onLongPress— are still out of scope for this PR).react-native-reanimated(useSharedValue+withTiming+useAnimatedStyle), now using the canonical pattern established by feat: modernize Switch to MD3 #4957 — motion tokens (theme.motion.duration.short2/short3,theme.motion.easing.standard) and the newuseReduceMotion()hook +ReduceMotion.Alwaysconfig so the OS /PaperProviderreduce-motion setting is honored. Reads the newisKeyboardFocusEventshared helper fromsrc/utils/and Adrian'swebNoOutline = { outline: 'none' } as unknown as ViewStylepattern for the browser default-outline suppression.Why not SVG
Compose Material3 draws the checkmark as a
PathwithstrokeFraction0 → 1. Doing the same in RN would mean addingreact-native-svgas 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.TouchableRipple40dp tap target withrippleColorpulled from the MD3 visual state; animated 18dp container; focus ring with:focus-visiblefilter on web via the sharedisKeyboardFocusEventhelper; locale-aware reveal-mask; view-based checkmark + dash; forwardedstyleplus allPressable/TouchableRippleprops via...rest;'mixed'a11y state for indeterminate. Animations driven byreact-native-reanimatedwith motion tokens +useReduceMotion.src/components/Checkbox/tokens.ts:CheckboxTokensexporting the 4 spec dimensions (containerSize,containerRadius,outlineWidth,stateLayerSize) plus the colour-role table (containerColor,outlineColor, etc.). Merged via{ ...sizes, ...colors }to mirror theSwitchTokenspattern from feat: modernize Switch to MD3 #4957.src/components/Checkbox/utils.ts:getSelectionVisualStatereturns the full colour + opacity picture for any(selected × hovered × pressed × disabled × error × customColor)combination. Focus is rendered by the component (per the MD3 spec note: focus is an outline ring, not a state-layer fill), so it deliberately doesn't appear in the visual-state helper. The legacygetSelectionControlColorhelper that used to live here (only consumed byRadioButtonAndroid) has been moved toRadioButton/utils.ts.src/components/RadioButton/utils.ts: now hostsgetSelectionControlColor(moved fromCheckbox/utils.ts). Cleans up the awkward back-compat re-export and keeps Checkbox/utils.ts focused on MD3 checkbox concerns.src/components/RadioButton/RadioButtonAndroid.tsx: import path updated to the local./utils.getSelectionControlColormigrated tosrc/components/__tests__/RadioButton/utils.test.tsx; the oldsrc/components/__tests__/Checkbox/utils.test.tsxis removed.example/src/Examples/CheckboxExample.tsx: added the error variant row and a Parent / children section that demonstrates an indeterminate parent checkbox whose state derives from three children.Out of scope
<StateLayer />as a shared primitive (can follow once Radio + Switch land and the pattern is proven).Pressableprops (accessibilityLabel,hitSlop,onLongPress, etc.). Already addedstyle; the rest can land in a follow-up if reviewers want a wider API surface.Test plan
mainafter feat: modernize Switch to MD3 #4957 landed; now consumes the sharedisKeyboardFocusEventhelper,ReduceMotionContext, and motion tokens introduced there.yarn typescriptclean (theexample/src/Examples/AppbarExample.tsxTS2590 error is pre-existing onmainand unaffected by this branch).yarn lintcleanyarn jest: 738/739 (1 skipped, pre-existing); 161/161 snapshots pass. The legacygetSelectionControlColortest suite was moved from__tests__/Checkbox/utils.test.tsxto__tests__/RadioButton/utils.test.tsxalongside its siblinggetSelectionControlIOSColortests — same coverage, just relocated to match the helper's new home.Visuals
Each video cycles through
unchecked → checked → indeterminate → uncheckedin both light and dark themes.