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

@fabriziocucci fabriziocucci commented May 26, 2026

Summary

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.
  • The tap target uses TouchableRipple (borderless + centered), matching IconButton/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 ships PlatformColor + alpha for android_ripple, but the animation lands today). rippleColor is fed by getSelectionVisualState({ ..., pressed: true }) so the ripple honours the MD3 selected/unselected colour flip on press.
  • Focus indicator: 3dp ring at theme.colors.secondary with the 2dp outer-offset from md.sys.state.focusIndicator. On web, the ring is gated on :focus-visible via the shared isKeyboardFocusEvent helper (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's webNoOutline = { outline: 'none' } as unknown as ViewStyle pattern from feat: modernize Switch to MD3 #4957.
  • RTL: on web, the reveal-mask anchor flips via useLocale().direction === 'rtl' so the checkmark appears to draw from the leading edge. On native, I18nManager already mirrors the layout so the default left: 0 anchor 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).
  • Accessibility: the inner <Checkbox> now emits accessibilityState.checked === 'mixed' for status="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. Also disabled is now coerced via !!disabled to avoid leaking undefined.
  • New style?: StyleProp<ViewStyle> prop forwarded to the underlying Pressable, per Copilot's review (the rest of the additional Pressable props — accessibilityLabel, hitSlop, onLongPress — are still out of scope for this PR).
  • Animations: 100ms fill + 150ms checkmark reveal driven by 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 new useReduceMotion() hook + ReduceMotion.Always config so the OS / PaperProvider reduce-motion setting is honored. Reads the new isKeyboardFocusEvent shared helper from src/utils/ and Adrian's webNoOutline = { outline: 'none' } as unknown as ViewStyle pattern for the browser default-outline suppression.

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. TouchableRipple 40dp tap target with rippleColor pulled from the MD3 visual state; animated 18dp container; focus ring with :focus-visible filter on web via the shared isKeyboardFocusEvent helper; locale-aware reveal-mask; view-based checkmark + dash; forwarded style plus all Pressable/TouchableRipple props via ...rest; 'mixed' a11y state for indeterminate. Animations driven by react-native-reanimated with motion tokens + useReduceMotion.
  • NEW src/components/Checkbox/tokens.ts: CheckboxTokens exporting the 4 spec dimensions (containerSize, containerRadius, outlineWidth, stateLayerSize) plus the colour-role table (containerColor, outlineColor, etc.). Merged via { ...sizes, ...colors } to mirror the SwitchTokens pattern from feat: modernize Switch to MD3 #4957.
  • src/components/Checkbox/utils.ts: getSelectionVisualState returns 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 legacy getSelectionControlColor helper that used to live here (only consumed by RadioButtonAndroid) has been moved to RadioButton/utils.ts.
  • src/components/RadioButton/utils.ts: now hosts getSelectionControlColor (moved from Checkbox/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.
  • Tests for getSelectionControlColor migrated to src/components/__tests__/RadioButton/utils.test.tsx; the old src/components/__tests__/Checkbox/utils.test.tsx is 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.
  • 9 snapshots auto-updated to reflect the new render tree.

Out of scope

  • RadioButton modernization (separate PR with the same pattern).
  • Extracting <StateLayer /> as a shared primitive (can follow once Radio + Switch land and the pattern is proven).
  • Forwarding additional Pressable props (accessibilityLabel, hitSlop, onLongPress, etc.). Already added style; the rest can land in a follow-up if reviewers want a wider API surface.

Test plan

  • Rebased onto main after feat: modernize Switch to MD3 #4957 landed; now consumes the shared isKeyboardFocusEvent helper, ReduceMotionContext, and motion tokens introduced there.
  • yarn typescript clean (the example/src/Examples/AppbarExample.tsx TS2590 error is pre-existing on main and unaffected by this branch).
  • yarn lint clean
  • yarn jest: 738/739 (1 skipped, pre-existing); 161/161 snapshots pass. The legacy getSelectionControlColor test suite was moved from __tests__/Checkbox/utils.test.tsx to __tests__/RadioButton/utils.test.tsx alongside its sibling getSelectionControlIOSColor tests — same coverage, just relocated to match the helper's new home.

Visuals

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

Each video cycles through unchecked → checked → indeterminate → unchecked in both light and dark themes.

@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 thread src/components/Checkbox/Checkbox.tsx Outdated
Comment thread src/components/Checkbox/Checkbox.tsx Outdated
@fabriziocucci fabriziocucci force-pushed the feat/checkbox-md3-modernization-v2 branch 8 times, most recently from 3c1cb09 to 6ce573a Compare May 27, 2026 07:14
@callstack-bot
Copy link
Copy Markdown

callstack-bot commented May 27, 2026

Hey @fabriziocucci, thank you for your pull request 🤗. The documentation from this branch can be viewed here.

@fabriziocucci fabriziocucci force-pushed the feat/checkbox-md3-modernization-v2 branch 5 times, most recently from ec70715 to a775d07 Compare May 27, 2026 07:55
@adrcotfas
Copy link
Copy Markdown
Collaborator

Focus ring is now gone. Check #4957 review comments for how it was handled there.

Comment thread src/components/Checkbox/Checkbox.tsx Outdated
Comment thread src/components/Checkbox/Checkbox.tsx Outdated
Comment thread src/components/Checkbox/Checkbox.tsx Outdated
@fabriziocucci fabriziocucci force-pushed the feat/checkbox-md3-modernization-v2 branch 9 times, most recently from 4ec40c5 to dfe21d0 Compare May 27, 2026 12:10
@fabriziocucci fabriziocucci force-pushed the feat/checkbox-md3-modernization-v2 branch 7 times, most recently from da62e1c to 64df4f8 Compare May 28, 2026 12:56
@fabriziocucci fabriziocucci requested a review from adrcotfas May 28, 2026 16:43
@adrcotfas
Copy link
Copy Markdown
Collaborator

  • Focus ring does not have the right shape as in the specs and on Android there's an extra transparent rectangle (see screenshot of modified example screen)
Screenshot_1780057122
  • We should be using TouchableRipple to respect the specs (see screenshot); we won't be able to respect the ripple color until RN 86 which included PlatformColor and alpha for android_ripple but at least the animation is there (see other components)
  • Please update the example app so we showcase all the new states; an indeterminate parent checkbox adapting according to children would be awesome along with showcasing the error variant.
image

Copy link
Copy Markdown
Collaborator

@adrcotfas adrcotfas left a comment

Choose a reason for hiding this comment

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

See previous comment.

@fabriziocucci fabriziocucci force-pushed the feat/checkbox-md3-modernization-v2 branch from 64df4f8 to 1fe92ec Compare May 29, 2026 12:57
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 1fe92ec to 7f47d89 Compare May 29, 2026 13:08
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.

4 participants