Uh oh!
There was an error while loading. Please reload this page.
feat: add SplitButton component - #4998
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new Material Design 3 SplitButton component to React Native Paper as part of the v6 modernization effort, and wires it into the library’s exports, docs, and example app.
Changes:
- Introduces
SplitButtoncomponent implementation with size/mode tokens and styling utilities. - Adds unit tests for SplitButton rendering/handlers and several utility helpers.
- Exposes the component publicly and integrates it into docs + the example app (screen + docs config/theme color docs data).
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/index.tsx | Exports SplitButton and SplitButtonProps from the public entrypoint. |
| src/components/SplitButton/utils.ts | Adds MD3 color/shape/size helpers, ripple color derivation, and hitSlop expansion logic. |
| src/components/SplitButton/tokens.ts | Introduces SplitButton size/shape/elevation tokens. |
| src/components/SplitButton/SplitButton.tsx | Implements the SplitButton component UI, interaction handling, accessibility, and pressed-elevation/shape behavior. |
| src/components/SplitButton/index.ts | Barrel export for the new component and its props. |
| src/components/tests/SplitButton.test.tsx | Adds tests for SplitButton rendering, interaction separation, accessibility state, and several utils. |
| example/src/Examples/SplitButtonExample.tsx | Adds a SplitButton example screen with Menu-based playground and showcases. |
| example/src/ExampleList.tsx | Registers the SplitButton example in the example app’s main list. |
| docs/static/llms.txt | Adds SplitButton docs link to the LLMs index. |
| docs/src/data/themeColors.js | Adds SplitButton entries to the theme color documentation data. |
| docs/docusaurus.config.js | Registers SplitButton in the docs sidebar/config. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
satya164
left a comment
There was a problem hiding this comment.
the outline split button design doesn't seem to match the screenshots on md guidelines. they look very dark
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
draggie
commented
Jun 25, 2026
Improved PR @satya164 |
satya164
left a comment
There was a problem hiding this comment.
The design doesn't quite match Material design guidelines
MD:

Ours:

Specifically the following:
- Chevron icon
- Border radius on inner sides
We're also missing expanded state. In MD3, the trailing segment becomes circular (which we can skip for now as we don't have shape morphing), and the chevron animates and rotates. See the video here https://m3.material.io/components/split-button/overview
Also, the documentation is missing. Update docs/component-docs.config.ts
| return { | ||
| containerColor, | ||
| contentColor, | ||
| borderColor: isOutlined ? theme.colors.outlineVariant : 'transparent', |
There was a problem hiding this comment.
Need to handle this for disabled according to MD3 tokens.
| style={[ | ||
| styles.label, | ||
| icon || loading | ||
| ? { marginStart: sizeStyle.leadingIconSize / 3 } |
There was a problem hiding this comment.
The spacing shouldn't be a division of icon size. Material uses multiplier of 4, e.g. 4, 8, 12, 16 etc. This division produces fractions.
| export type SplitButtonNormalizedMode = | ||
| | 'filled' | ||
| | 'tonal' | ||
| | 'elevated' | ||
| | 'outlined'; | ||
| export const normalizeSplitButtonMode = ( | ||
| mode: SplitButtonMode | ||
| ): SplitButtonNormalizedMode => { | ||
| return mode; | ||
| }; |
There was a problem hiding this comment.
This SplitButtonNormalizedMode type is same as SplitButtonMode and normalizeSplitButtonMode does nothing. Remove them.
| return ( | ||
| <ScreenWrapper> | ||
| <List.Section title="Playground"> | ||
| <View style={styles.playground}> | ||
| <Menu | ||
| visible={menuVisible} | ||
| onDismiss={() => setMenuVisible(false)} | ||
| anchorPosition="bottom" | ||
| anchor={ | ||
| <SplitButton | ||
| label="Send" | ||
| icon="send" | ||
| mode="filled" | ||
| disabled={disabled} | ||
| loading={loading} | ||
| onPress={() => {}} | ||
| onTrailingPress={() => setMenuVisible(true)} | ||
| trailingAccessibilityLabel="Show send options" | ||
| trailingAccessibilityState={{ expanded: menuVisible }} | ||
| /> | ||
| } | ||
| > | ||
| <Menu.Item | ||
| leadingIcon="schedule" | ||
| title="Schedule send" | ||
| onPress={() => setMenuVisible(false)} | ||
| /> | ||
| <Menu.Item | ||
| leadingIcon="content-save" | ||
| title="Save draft" | ||
| onPress={() => setMenuVisible(false)} | ||
| /> | ||
| </Menu> | ||
| </View> | ||
| <List.Item | ||
| title="Disabled" | ||
| right={() => <Switch value={disabled} onValueChange={setDisabled} />} | ||
| /> | ||
| <List.Item | ||
| title="Loading" | ||
| right={() => <Switch value={loading} onValueChange={setLoading} />} | ||
| /> | ||
| </List.Section> | ||
| <List.Section title="Modes"> | ||
| <View style={styles.row}> | ||
| {modes.map((mode) => ( | ||
| <SplitButton | ||
| key={mode} | ||
| mode={mode} | ||
| icon="plus" | ||
| label={mode} | ||
| onPress={() => {}} | ||
| onTrailingPress={() => {}} | ||
| trailingAccessibilityLabel={`${mode} options`} | ||
| /> | ||
| ))} | ||
| </View> | ||
| </List.Section> | ||
| <List.Section title="Custom"> | ||
| <View style={styles.column}> | ||
| <SplitButton | ||
| mode="outlined" | ||
| icon="palette" | ||
| label="Custom color" | ||
| buttonColor={theme.colors.tertiaryContainer} | ||
| textColor={theme.colors.onTertiaryContainer} | ||
| onPress={() => {}} | ||
| onTrailingPress={() => {}} | ||
| trailingAccessibilityLabel="Custom color options" | ||
| /> | ||
| <SplitButton | ||
| mode="filled" | ||
| label="Custom label style" | ||
| icon="format-bold" | ||
| labelStyle={styles.boldLabel} | ||
| onPress={() => {}} | ||
| onTrailingPress={() => {}} | ||
| trailingAccessibilityLabel="Custom label options" | ||
| /> | ||
| </View> | ||
| </List.Section> | ||
| </ScreenWrapper> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Include examples for multiple sizes
| export const splitButtonElevation = { | ||
| disabled: 0, | ||
| enabled: 1, | ||
| pressed: 2, |
| /> | ||
| ) : null} | ||
| <Text | ||
| variant={sizeStyle.labelVariant} |
There was a problem hiding this comment.
labelTextStyle also applies font styles. Keep only one to avoid confusion on which one is applied.
| export type Props = $Omit< | ||
| ViewProps, | ||
| 'children' | 'style' | 'onPress' | 'onPressIn' | 'onPressOut' |
There was a problem hiding this comment.
View doesn't have these
| 'children'|'style'|'onPress'|'onPressIn'|'onPressOut' | |
| 'children'|'style' |
| return hitSlop; | ||
| } | ||
| const insetHitSlop = (hitSlop || {}) as Insets; |
There was a problem hiding this comment.
Don't use as type. It's also unnecessary to fallback to {}. undefined/null can spread fine.
| it('renders a filled split button by default', async () => { | ||
| await renderSplitButton(); | ||
| expect(screen.getByTestId('split-button-label')).toHaveTextContent('Send'); | ||
| expect(screen.getByTestId('split-button-container')).toHaveStyle({ | ||
| height: 40, | ||
| }); | ||
| expect(screen.getByTestId('split-button-leading-container')).toBeTruthy(); | ||
| expect(screen.getByTestId('split-button-trailing-container')).toBeTruthy(); | ||
| }); | ||
| it('calls leading and trailing press handlers separately', async () => { | ||
| const user = userEvent.setup(); | ||
| const onPress = jest.fn(); | ||
| const onTrailingPress = jest.fn(); | ||
| await renderSplitButton({ onPress, onTrailingPress }); | ||
| await user.press(screen.getByTestId('split-button-leading')); | ||
| await user.press(screen.getByTestId('split-button-trailing')); | ||
| expect(onPress).toHaveBeenCalledTimes(1); | ||
| expect(onTrailingPress).toHaveBeenCalledTimes(1); | ||
| }); | ||
| it('calls leading and trailing press-in and press-out handlers separately', async () => { | ||
| const onPressIn = jest.fn(); | ||
| const onPressOut = jest.fn(); | ||
| const onTrailingPressIn = jest.fn(); | ||
| const onTrailingPressOut = jest.fn(); | ||
| await renderSplitButton({ | ||
| onPressIn, | ||
| onPressOut, | ||
| onTrailingPressIn, | ||
| onTrailingPressOut, | ||
| }); | ||
| await fireEvent(screen.getByTestId('split-button-leading'), 'onPressIn'); | ||
| await fireEvent(screen.getByTestId('split-button-leading'), 'onPressOut'); | ||
| await fireEvent(screen.getByTestId('split-button-trailing'), 'onPressIn'); | ||
| await fireEvent(screen.getByTestId('split-button-trailing'), 'onPressOut'); | ||
| expect(onPressIn).toHaveBeenCalledTimes(1); | ||
| expect(onPressOut).toHaveBeenCalledTimes(1); | ||
| expect(onTrailingPressIn).toHaveBeenCalledTimes(1); | ||
| expect(onTrailingPressOut).toHaveBeenCalledTimes(1); | ||
| }); | ||
| it('uses resting inner-corner tokens for both sides', async () => { | ||
| const theme = getTheme(); | ||
| await renderSplitButton(); | ||
| expect(screen.getByTestId('split-button-leading-container')).toHaveStyle({ | ||
| borderTopEndRadius: theme.shapes.corner.extraSmall, | ||
| borderBottomEndRadius: theme.shapes.corner.extraSmall, | ||
| }); | ||
| expect(screen.getByTestId('split-button-trailing-container')).toHaveStyle({ | ||
| borderTopStartRadius: theme.shapes.corner.extraSmall, | ||
| borderBottomStartRadius: theme.shapes.corner.extraSmall, | ||
| }); | ||
| }); | ||
| it('marks both press targets disabled when disabled', async () => { | ||
| await renderSplitButton({ disabled: true }); | ||
| expect(screen.getByTestId('split-button-leading')).toBeDisabled(); | ||
| expect(screen.getByTestId('split-button-trailing')).toBeDisabled(); | ||
| }); | ||
| it('passes custom styles to the correct target', async () => { | ||
| await renderSplitButton({ | ||
| leadingButtonStyle: styles.leading, | ||
| trailingButtonStyle: styles.trailing, | ||
| labelStyle: styles.label, | ||
| }); | ||
| expect(screen.getByTestId('split-button-leading-container')).toHaveStyle( | ||
| styles.leading | ||
| ); | ||
| expect(screen.getByTestId('split-button-trailing-container')).toHaveStyle( | ||
| styles.trailing | ||
| ); | ||
| expect(screen.getByTestId('split-button-label')).toHaveStyle(styles.label); | ||
| }); | ||
| it('merges trailing accessibility state with expanded state', async () => { | ||
| await renderSplitButton({ | ||
| trailingAccessibilityState: { expanded: true }, | ||
| }); | ||
| expect(screen.getByTestId('split-button-trailing')).toHaveProp( | ||
| 'accessibilityState', | ||
| expect.objectContaining({ expanded: true }) | ||
| ); | ||
| }); | ||
| it('does not add SplitButton test IDs unless testID is provided', async () => { | ||
| await render( | ||
| <SplitButton label="Send" onPress={() => {}} onTrailingPress={() => {}} /> | ||
| ); | ||
| expect(screen.queryByTestId('split-button-container')).toBeNull(); | ||
| expect(screen.queryByTestId('split-button-leading')).toBeNull(); | ||
| expect(screen.queryByTestId('split-button-trailing')).toBeNull(); | ||
| }); | ||
| describe('SplitButton utils', () => { | ||
| it('normalizes supported MD3 modes', () => { | ||
| expect(normalizeSplitButtonMode('filled')).toBe('filled'); | ||
| expect(normalizeSplitButtonMode('tonal')).toBe('tonal'); | ||
| expect(normalizeSplitButtonMode('outlined')).toBe('outlined'); | ||
| }); | ||
| it('resolves MD3 color roles for modes', () => { | ||
| const theme = getTheme(); | ||
| expect(getSplitButtonColors({ theme, mode: 'filled' }).containerColor).toBe( | ||
| theme.colors.primary | ||
| ); | ||
| expect(getSplitButtonColors({ theme, mode: 'tonal' }).contentColor).toBe( | ||
| theme.colors.onSecondaryContainer | ||
| ); | ||
| expect(getSplitButtonColors({ theme, mode: 'elevated' }).contentColor).toBe( | ||
| theme.colors.primary | ||
| ); | ||
| expect(getSplitButtonColors({ theme, mode: 'outlined' }).borderColor).toBe( | ||
| theme.colors.outlineVariant | ||
| ); | ||
| }); | ||
| it('resolves ripple colors from overrides, string content colors, and opaque colors', () => { | ||
| const theme = getTheme(); | ||
| const customRippleColor = 'rgba(1, 2, 3, 0.4)'; | ||
| expect( | ||
| getSplitButtonRippleColor({ | ||
| contentColor: theme.colors.primary, | ||
| customRippleColor, | ||
| }) | ||
| ).toBe(customRippleColor); | ||
| expect( | ||
| getSplitButtonRippleColor({ contentColor: theme.colors.primary }) | ||
| ).toBe('rgba(103, 80, 164, 0.1)'); | ||
| expect( | ||
| getSplitButtonRippleColor({ contentColor: PlatformColor('label') }) | ||
| ).toBeUndefined(); | ||
| }); | ||
| it('resolves per-size tokens against theme shape values', () => { | ||
| const theme = getTheme(); | ||
| const sizeStyle = getSplitButtonSizeStyle({ theme, size: 'large' }); | ||
| expect(sizeStyle.containerHeight).toBe(96); | ||
| expect(sizeStyle.trailingIconSize).toBe(38); | ||
| expect(sizeStyle.innerRadius).toBe(theme.shapes.corner.small); | ||
| expect(sizeStyle.innerPressedRadius).toBe( | ||
| theme.shapes.corner.largeIncreased | ||
| ); | ||
| }); | ||
| it('uses logical leading and trailing shapes', () => { | ||
| expect( | ||
| getSplitButtonLeadingShape({ containerRadius: 20, innerRadius: 4 }) | ||
| ).toEqual({ | ||
| borderTopStartRadius: 20, | ||
| borderBottomStartRadius: 20, | ||
| borderTopEndRadius: 4, | ||
| borderBottomEndRadius: 4, | ||
| }); | ||
| expect( | ||
| getSplitButtonTrailingShape({ containerRadius: 20, innerRadius: 4 }) | ||
| ).toEqual({ | ||
| borderTopStartRadius: 4, | ||
| borderBottomStartRadius: 4, | ||
| borderTopEndRadius: 20, | ||
| borderBottomEndRadius: 20, | ||
| }); | ||
| }); | ||
| it('expands small visual sizes to a 48dp touch target', () => { | ||
| expect(getSplitButtonHitSlop({ size: 'extra-small' })).toEqual({ | ||
| top: 8, | ||
| bottom: 8, | ||
| }); | ||
| expect(getSplitButtonHitSlop({ size: 'small' })).toEqual({ | ||
| top: 4, | ||
| bottom: 4, | ||
| }); | ||
| expect(getSplitButtonHitSlop({ size: 'medium' })).toBeUndefined(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
remove tests that test implementation details such as exact style values. only test public behavior.
| expect(normalizeSplitButtonMode('filled')).toBe('filled'); | ||
| expect(normalizeSplitButtonMode('tonal')).toBe('tonal'); | ||
| expect(normalizeSplitButtonMode('outlined')).toBe('outlined'); |
There was a problem hiding this comment.
these tests are testing nothing. normalizeSplitButtonMode doesn't do anything.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/components/SplitButton/utils.ts:160
- Outlined mode uses
outlineVariant, but the SplitButton theme documentation added in this PR specifies the MD3outlinerole (docs/src/data/themeColors.ts:326). This makes the rendered border lighter than the documented/spec color. Usetheme.colors.outlinehere.
borderColor: isOutlined ? theme.colors.outlineVariant : 'transparent',
src/components/SplitButton/SplitButton.tsx:289
- The ripple is derived before the
labelStylecolor is resolved. WhenlabelStyle.coloris set, the label and both icons use that color below, but the pressed state layer still uses the mode's default content color. Resolve the label color inside this memo so the state layer follows the content actually rendered.
const rippleColor = React.useMemo(
() =>
getSplitButtonRippleColor({
contentColor: colors.contentColor,
customRippleColor,
}),
[colors.contentColor, customRippleColor]
);
src/components/SplitButton/SplitButton.tsx:460
splitButtonElevation.pressedis unreachable: elevation is based only on mode anddisabled, so an elevated SplitButton remains at level 1 throughout both press flows. Wire the segment press-in/out handlers to apply the newly defined pressed elevation and restore the enabled elevation on release.
const elevation = isElevationEntitled
? splitButtonElevation.enabled
: splitButtonElevation.disabled;


Motivation
Implements the new Material Design 3 SplitButton component as part of the v6 component modernization work.
The component provides a primary leading action and a separate trailing action for contextual options. It reuses the modernized Button API direction
from #4928/#4943: explicit
label/iconprops, MD3 mode names (filled,tonal,elevated,outlined), derived ripple/state-layer colors,extracted component tokens, size metrics, shape tokens, disabled/loading states, and separate accessibility/press handlers for each button segment.
This also adds the component to the public exports, documentation generation config, theme color docs data, and the example app. The example includes a
Menu-based playground, mode showcase, loading/disabled controls, and custom color/label styling.
Related issue
Closes#4986
Related to #4928 and #4943
Test plan
yarn lint-no-fixsrc/components/__tests__/TextInput.test.tsx.yarn typescriptyarn test --watchman=falseyarn docs buildManual verification in the example app:
SplitButtonexample screen.Sendaction and trailing menu action separately.Disabledand verify both press targets are disabled and styled correctly.Loadingand verify the leading icon is replaced with a spinner.filled,tonal,elevated, andoutlined.